ssl-security.md 24 KB

SSL/TLS & Security Reference

Comprehensive guide to Nginx SSL/TLS configuration, Let's Encrypt automation, security headers, rate limiting, access control, and mutual TLS.


Table of Contents

  1. TLS Configuration
  2. Let's Encrypt & Certbot
  3. Certificate Management
  4. HSTS
  5. OCSP Stapling
  6. Security Headers
  7. Rate Limiting
  8. IP Restrictions
  9. Basic Authentication
  10. Mutual TLS (mTLS)
  11. HTTP to HTTPS Redirect

TLS Configuration

Modern Configuration (TLS 1.3 Only)

For services where all clients support TLS 1.3 (modern browsers, API clients you control).

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    # TLS 1.3 only
    ssl_protocols TLSv1.3;

    # TLS 1.3 ciphers are not configurable via ssl_ciphers
    # They are negotiated automatically:
    # TLS_AES_256_GCM_SHA384
    # TLS_CHACHA20_POLY1305_SHA256
    # TLS_AES_128_GCM_SHA256

    ssl_prefer_server_ciphers off;
}

Intermediate Configuration (TLS 1.2 + 1.3)

Recommended for most production sites. Compatible with all modern browsers.

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    # TLS 1.2 and 1.3
    ssl_protocols TLSv1.2 TLSv1.3;

    # Cipher suite for TLS 1.2 (TLS 1.3 ciphers are automatic)
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    # DH parameters for DHE ciphers
    ssl_dhparam /etc/nginx/dhparam.pem;

    # Session caching
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
}

Generate DH Parameters

# Generate 4096-bit DH parameters (takes several minutes)
openssl dhparam -out /etc/nginx/dhparam.pem 4096

# Or use pre-generated params from Mozilla (faster, still secure)
curl -sL https://ssl-config.mozilla.org/ffdhe2048.txt > /etc/nginx/dhparam.pem

SSL Session Configuration

# Shared session cache across all worker processes
# 10m = 10MB, enough for ~40,000 sessions
ssl_session_cache shared:SSL:10m;

# Session lifetime
ssl_session_timeout 1d;

# Disable session tickets (better forward secrecy)
# Enable only if you rotate ticket keys regularly
ssl_session_tickets off;

TLS Version Comparison

Version Status Performance Security Support
TLS 1.0 Deprecated Slow Weak Drop immediately
TLS 1.1 Deprecated Slow Weak Drop immediately
TLS 1.2 Active Good Strong All modern browsers
TLS 1.3 Preferred Best (0-RTT) Strongest 95%+ browsers

Let's Encrypt & Certbot

Installation

# Debian/Ubuntu
sudo apt update
sudo apt install certbot python3-certbot-nginx

# RHEL/Fedora
sudo dnf install certbot python3-certbot-nginx

# Alpine
sudo apk add certbot certbot-nginx

# Snap (universal)
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot

Obtaining Certificates

Nginx Plugin (Easiest)

Certbot automatically modifies your nginx config.

# Single domain
sudo certbot --nginx -d example.com

# Multiple domains
sudo certbot --nginx -d example.com -d www.example.com -d api.example.com

# Non-interactive (for automation)
sudo certbot --nginx --non-interactive --agree-tos \
    --email admin@example.com -d example.com

Webroot Method (No Restart)

Use when you don't want certbot to modify your nginx config.

# Add this to your nginx server block first
location /.well-known/acme-challenge/ {
    root /var/www/certbot;
}
sudo certbot certonly --webroot -w /var/www/certbot -d example.com

Standalone Method

Certbot runs its own temporary web server (requires port 80 to be free).

# Stop nginx first
sudo systemctl stop nginx

sudo certbot certonly --standalone -d example.com

# Restart nginx
sudo systemctl start nginx

DNS Challenge (Wildcard Certificates)

Required for wildcard certificates (*.example.com).

# Manual DNS challenge
sudo certbot certonly --manual --preferred-challenges dns -d "*.example.com"

# With DNS plugin (Cloudflare example)
sudo certbot certonly --dns-cloudflare \
    --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
    -d example.com -d "*.example.com"

Cloudflare credentials file:

# /etc/letsencrypt/cloudflare.ini
dns_cloudflare_api_token = your-api-token-here
# Secure the credentials file
sudo chmod 600 /etc/letsencrypt/cloudflare.ini

Auto-Renewal

Systemd Timer (Recommended)

Certbot usually installs this automatically.

# /etc/systemd/system/certbot.timer
[Unit]
Description=Run certbot twice daily

[Timer]
OnCalendar=*-*-* 00,12:00:00
RandomizedDelaySec=3600
Persistent=true

[Install]
WantedBy=timers.target
# /etc/systemd/system/certbot.service
[Unit]
Description=Certbot renewal

[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet
# Enable and start
sudo systemctl enable certbot.timer
sudo systemctl start certbot.timer

# Check status
sudo systemctl list-timers certbot.timer

Cron Alternative

# /etc/cron.d/certbot
0 0,12 * * * root certbot renew --quiet --deploy-hook "systemctl reload nginx"

Renewal Hooks

# Test renewal with hooks
sudo certbot renew --dry-run \
    --pre-hook "echo 'Before renewal'" \
    --post-hook "systemctl reload nginx" \
    --deploy-hook "echo 'Certificate renewed'"

# Hook scripts (placed in /etc/letsencrypt/renewal-hooks/)
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
systemctl reload nginx
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Certificate File Locations

/etc/letsencrypt/live/example.com/
├── cert.pem          # Domain certificate only
├── chain.pem         # Intermediate CA certificate(s)
├── fullchain.pem     # cert.pem + chain.pem (use this for ssl_certificate)
├── privkey.pem       # Private key (use this for ssl_certificate_key)
└── README

Certificate Management

Certificate Chain Configuration

# fullchain.pem includes: domain cert + intermediate CA cert(s)
ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

# Trusted certificate for OCSP stapling verification
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;

Verify Certificate Chain

# Check certificate details
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -text -noout

# Verify chain
openssl verify -CAfile /etc/letsencrypt/live/example.com/chain.pem \
    /etc/letsencrypt/live/example.com/cert.pem

# Check expiration
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate

# Test SSL from outside
openssl s_client -connect example.com:443 -servername example.com

Multiple Certificates (RSA + ECDSA)

Serve different certificate types for maximum compatibility and performance.

server {
    listen 443 ssl http2;
    server_name example.com;

    # RSA certificate (compatibility)
    ssl_certificate     /etc/nginx/certs/example.com-rsa.pem;
    ssl_certificate_key /etc/nginx/certs/example.com-rsa.key;

    # ECDSA certificate (performance) - Nginx picks the best one
    ssl_certificate     /etc/nginx/certs/example.com-ecdsa.pem;
    ssl_certificate_key /etc/nginx/certs/example.com-ecdsa.key;
}

HSTS

HTTP Strict Transport Security tells browsers to always use HTTPS for this domain.

Basic HSTS

# 2-year max-age (recommended for production)
add_header Strict-Transport-Security "max-age=63072000" always;

HSTS with Subdomains

# Apply to all subdomains as well
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

HSTS Preload

Submit to browser preload list (permanently enforced, difficult to undo).

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

Before enabling preload:

  1. Ensure ALL subdomains support HTTPS
  2. Start with a short max-age (e.g., 300) and test
  3. Submit at https://hstspreload.org/

Gradual HSTS Rollout

# Step 1: Short max-age, monitor for issues (1 week)
add_header Strict-Transport-Security "max-age=604800" always;

# Step 2: Increase to 1 month
add_header Strict-Transport-Security "max-age=2592000" always;

# Step 3: Include subdomains
add_header Strict-Transport-Security "max-age=2592000; includeSubDomains" always;

# Step 4: Full production (2 years + preload)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

OCSP Stapling

OCSP stapling embeds the certificate's revocation status in the TLS handshake, improving connection speed and privacy.

server {
    listen 443 ssl http2;
    server_name example.com;

    # Enable OCSP stapling
    ssl_stapling on;

    # Verify OCSP response using trusted CA cert
    ssl_stapling_verify on;

    # CA cert chain for verification (intermediate + root)
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;

    # DNS resolver for OCSP responder lookup
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;
}

Verify OCSP Stapling

# Test OCSP stapling
openssl s_client -connect example.com:443 -servername example.com -status 2>/dev/null | \
    grep -A 17 "OCSP Response Status"

# Should show: "OCSP Response Status: successful (0x0)"

Security Headers

Complete Security Headers Configuration

# /etc/nginx/includes/security-headers.conf

# Prevent MIME type sniffing
add_header X-Content-Type-Options nosniff always;

# Clickjacking protection
add_header X-Frame-Options DENY always;
# Or allow same-origin framing:
# add_header X-Frame-Options SAMEORIGIN always;

# XSS Protection (legacy browsers)
add_header X-XSS-Protection "1; mode=block" always;

# Referrer Policy
add_header Referrer-Policy strict-origin-when-cross-origin always;

# Permissions Policy (formerly Feature-Policy)
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;

# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;

# Cross-Origin policies
add_header Cross-Origin-Opener-Policy same-origin always;
add_header Cross-Origin-Resource-Policy same-origin always;
add_header Cross-Origin-Embedder-Policy require-corp always;

Usage

server {
    listen 443 ssl http2;
    server_name example.com;

    include /etc/nginx/includes/security-headers.conf;

    # ... rest of config
}

Header Reference

Header Value Purpose
X-Content-Type-Options nosniff Prevent MIME type sniffing
X-Frame-Options DENY or SAMEORIGIN Prevent clickjacking
X-XSS-Protection 1; mode=block Legacy XSS filter
Referrer-Policy strict-origin-when-cross-origin Control referrer leakage
Permissions-Policy camera=(), ... Disable browser features
Content-Security-Policy default-src 'self'; ... Control resource loading
Strict-Transport-Security max-age=63072000; ... Force HTTPS
Cross-Origin-Opener-Policy same-origin Isolate browsing context
Cross-Origin-Resource-Policy same-origin Prevent cross-origin reads

Content-Security-Policy Examples

# Minimal CSP (strict)
add_header Content-Security-Policy "default-src 'self';" always;

# With Google Fonts and Analytics
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://www.google-analytics.com;" always;

# API-only (no HTML rendering)
add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none';" always;

# Report-only mode (for testing)
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report;" always;

Rate Limiting

Basic Rate Limiting

http {
    # Define rate limit zone
    # $binary_remote_addr = client IP (compact binary, 4 or 16 bytes)
    # zone=name:size     = shared memory zone name and size
    # rate=10r/s         = 10 requests per second
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;

    server {
        location / {
            # Apply rate limit
            # burst=20  = allow 20 excess requests to queue
            # nodelay   = process burst immediately (don't throttle)
            limit_req zone=general burst=20 nodelay;

            # Custom status code (default is 503)
            limit_req_status 429;

            proxy_pass http://backend;
        }
    }
}

Multiple Rate Limit Zones

http {
    # Global rate limit: 30 req/s per IP
    limit_req_zone $binary_remote_addr zone=global:10m rate=30r/s;

    # Login rate limit: 5 req/min per IP
    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

    # API rate limit: by API key
    limit_req_zone $http_x_api_key zone=api:10m rate=100r/s;

    server {
        # Global limit applies everywhere
        limit_req zone=global burst=50 nodelay;

        location /api/login {
            # Stricter limit for login endpoint
            limit_req zone=login burst=3 nodelay;
            proxy_pass http://backend;
        }

        location /api/ {
            # API key-based limiting
            limit_req zone=api burst=200 nodelay;
            proxy_pass http://backend;
        }
    }
}

Connection Limiting

Limit the number of simultaneous connections per IP.

http {
    # Define connection limit zone
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

    server {
        # Max 20 simultaneous connections per IP
        limit_conn conn_limit 20;

        # Limit bandwidth per connection (useful for downloads)
        limit_rate 1m;               # 1MB/s per connection
        limit_rate_after 10m;        # Full speed for first 10MB

        location /downloads/ {
            # Tighter limits for download section
            limit_conn conn_limit 5;
            limit_rate 500k;
        }
    }
}

Rate Limiting with Whitelisting

http {
    # Map to identify whitelisted IPs
    geo $rate_limit {
        default         1;
        10.0.0.0/8      0;    # Internal network
        192.168.0.0/16  0;    # Private network
        203.0.113.50    0;    # Monitoring server
    }

    # Only apply rate limiting to non-whitelisted IPs
    map $rate_limit $rate_limit_key {
        0 "";
        1 $binary_remote_addr;
    }

    limit_req_zone $rate_limit_key zone=api:10m rate=10r/s;

    server {
        location /api/ {
            limit_req zone=api burst=20 nodelay;
            proxy_pass http://backend;
        }
    }
}

Logging Rate-Limited Requests

http {
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    # Log rate-limited requests at warn level
    limit_req_log_level warn;

    # Custom log format for rate-limited requests
    log_format ratelimit '$remote_addr - $remote_user [$time_local] '
                         '"$request" $status $body_bytes_sent '
                         '"limit_req_status=$limit_req_status"';
}

IP Restrictions

Allow/Deny Directives

location /admin/ {
    # Allow specific IPs and ranges
    allow 10.0.0.0/8;
    allow 192.168.1.0/24;
    allow 203.0.113.50;

    # Deny everything else
    deny all;

    proxy_pass http://admin_backend;
}

Order matters: Nginx evaluates allow/deny rules in order and uses the first match.

Geo Module

Map client IP to a variable for conditional logic.

http {
    geo $allowed_country {
        default         no;
        10.0.0.0/8      yes;    # Internal
        203.0.0.0/8     yes;    # Example allowed range
    }

    server {
        location / {
            if ($allowed_country = no) {
                return 403;
            }
            proxy_pass http://backend;
        }
    }
}

GeoIP2 Module

For geo-blocking or geo-routing by country. Requires ngx_http_geoip2_module and MaxMind GeoLite2 database.

# Load GeoIP2 module
load_module modules/ngx_http_geoip2_module.so;

http {
    geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
        auto_reload 60m;
        $geoip2_metadata_country_build metadata build_epoch;
        $geoip2_data_country_code country iso_code;
        $geoip2_data_country_name country names en;
    }

    # Block specific countries
    map $geoip2_data_country_code $blocked_country {
        default no;
        XX      yes;    # Replace XX with country code
        YY      yes;
    }

    server {
        if ($blocked_country = yes) {
            return 403;
        }
    }
}

Combining IP and Authentication

location /admin/ {
    # Require BOTH IP match AND authentication
    satisfy all;

    allow 10.0.0.0/8;
    deny all;

    auth_basic "Admin Area";
    auth_basic_user_file /etc/nginx/.htpasswd;

    proxy_pass http://admin_backend;
}

location /internal/ {
    # Require EITHER IP match OR authentication
    satisfy any;

    allow 10.0.0.0/8;
    deny all;

    auth_basic "Internal Area";
    auth_basic_user_file /etc/nginx/.htpasswd;

    proxy_pass http://internal_backend;
}

Basic Authentication

Setup

# Install htpasswd utility
sudo apt install apache2-utils    # Debian/Ubuntu
sudo dnf install httpd-tools      # RHEL/Fedora

# Create password file with first user
sudo htpasswd -c /etc/nginx/.htpasswd admin

# Add additional users (no -c flag!)
sudo htpasswd /etc/nginx/.htpasswd user2

# Use bcrypt hashing (more secure, requires htpasswd 2.4+)
sudo htpasswd -B /etc/nginx/.htpasswd user3

# Secure the file
sudo chown root:www-data /etc/nginx/.htpasswd
sudo chmod 640 /etc/nginx/.htpasswd

Nginx Configuration

location /admin/ {
    auth_basic "Admin Area";
    auth_basic_user_file /etc/nginx/.htpasswd;

    proxy_pass http://admin_backend;
}

# Disable auth for specific sub-paths
location /admin/health {
    auth_basic off;
    proxy_pass http://admin_backend;
}

Auth for Entire Site with Exceptions

server {
    listen 443 ssl http2;
    server_name staging.example.com;

    # Global auth for staging environment
    auth_basic "Staging Environment";
    auth_basic_user_file /etc/nginx/.htpasswd;

    location / {
        proxy_pass http://backend;
    }

    # Exempt health checks and webhooks
    location /health {
        auth_basic off;
        proxy_pass http://backend;
    }

    location /webhooks/ {
        auth_basic off;
        proxy_pass http://backend;
    }
}

Mutual TLS (mTLS)

Mutual TLS requires both server and client to present certificates, providing strong authentication.

Server Configuration

server {
    listen 443 ssl http2;
    server_name api.example.com;

    # Server certificate (standard)
    ssl_certificate     /etc/nginx/certs/server.pem;
    ssl_certificate_key /etc/nginx/certs/server.key;

    # CA certificate that signed client certificates
    ssl_client_certificate /etc/nginx/certs/client-ca.pem;

    # Require client certificate
    ssl_verify_client on;
    # Or make it optional:
    # ssl_verify_client optional;

    # Verification depth (how many intermediate CAs to check)
    ssl_verify_depth 2;

    # CRL for revoked client certificates
    ssl_crl /etc/nginx/certs/client-revoked.crl;

    location / {
        # Pass client certificate info to backend
        proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
        proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
        proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
        proxy_set_header X-SSL-Client-Fingerprint $ssl_client_fingerprint;

        proxy_pass http://backend;
    }
}

Optional Client Certificate

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_client_certificate /etc/nginx/certs/client-ca.pem;
    ssl_verify_client optional;

    location /public/ {
        # No client cert required
        proxy_pass http://backend;
    }

    location /secure/ {
        # Require valid client cert for this path
        if ($ssl_client_verify != SUCCESS) {
            return 403;
        }
        proxy_pass http://secure_backend;
    }
}

Generate Client Certificates

# 1. Create CA (one-time)
openssl genrsa -out client-ca.key 4096
openssl req -new -x509 -days 3650 -key client-ca.key -out client-ca.pem \
    -subj "/CN=Client CA"

# 2. Generate client key and CSR
openssl genrsa -out client.key 2048
openssl req -new -key client.key -out client.csr \
    -subj "/CN=client-name/O=organization"

# 3. Sign with CA
openssl x509 -req -days 365 -in client.csr -CA client-ca.pem \
    -CAkey client-ca.key -CAcreateserial -out client.pem

# 4. Create PKCS12 bundle for browser import
openssl pkcs12 -export -out client.p12 \
    -inkey client.key -in client.pem -certfile client-ca.pem

# 5. Test with curl
curl --cert client.pem --key client.key https://api.example.com/

Client Certificate Variables

Variable Description
$ssl_client_verify SUCCESS, FAILED:reason, or NONE
$ssl_client_s_dn Subject DN of client certificate
$ssl_client_i_dn Issuer DN of client certificate
$ssl_client_serial Serial number of client certificate
$ssl_client_fingerprint SHA1 fingerprint of client certificate
$ssl_client_cert PEM-encoded client certificate
$ssl_client_raw_cert PEM-encoded client certificate (unescaped)
$ssl_client_escaped_cert URL-encoded client certificate

HTTP to HTTPS Redirect

Standard Redirect

# Redirect all HTTP to HTTPS
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

Catch-All Redirect

# Redirect ANY domain on HTTP to HTTPS
server {
    listen 80 default_server;
    server_name _;
    return 301 https://$host$request_uri;
}

Redirect with Let's Encrypt Exception

server {
    listen 80;
    server_name example.com www.example.com;

    # Allow ACME challenge for certificate renewal
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Redirect everything else to HTTPS
    location / {
        return 301 https://example.com$request_uri;
    }
}

WWW to Non-WWW (with HTTPS)

# Redirect www to non-www
server {
    listen 443 ssl http2;
    server_name www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    return 301 https://example.com$request_uri;
}

# Redirect HTTP www to HTTPS non-www
server {
    listen 80;
    server_name www.example.com;
    return 301 https://example.com$request_uri;
}

Redirect with Preserved POST Body

Note: 301 and 302 redirects convert POST to GET. Use 307/308 to preserve the method.

# 308 Permanent Redirect (preserves HTTP method)
server {
    listen 80;
    server_name api.example.com;
    return 308 https://api.example.com$request_uri;
}
Status Permanent Preserves Method
301 Yes No (POST → GET)
302 No No (POST → GET)
307 No Yes
308 Yes Yes