Let's Secure Me

Best practices and tips to secure your infrastructure.

Nginx SSL Hardening Checklist for 2026

TLS 1.3, HSTS, OCSP stapling, and HTTP/3 — every directive explained and verified.

A default Nginx TLS configuration will get you a passing grade, but it leaves performance and security on the table. Weak protocol versions linger, OCSP responses are fetched client-side, and HSTS is absent — all of which are low-hanging fruit for attackers and auditors alike.

This checklist distills the current best practices into concrete directives you can drop into your nginx.conf, verify with openssl and curl, and monitor in CI. Each item references its authoritative source — Mozilla's SSL Configuration Generator, Nginx documentation, or the relevant RFC — so you can justify every change to your team.

Prerequisites

  • Nginx 1.25.0+ (required for native HTTP/3 / QUIC support).
  • OpenSSL 3.0+ (ships with Ubuntu 22.04 / Debian 12 and later).
  • A valid TLS certificate and private key (e.g. from Let's Encrypt via Certbot).
  • Root or sudo access on the target host.

Check your versions before proceeding:

nginx -V 2>&1 | head -1
openssl version

1. Enforce TLS 1.3 (and TLS 1.2 minimum)

TLS 1.3 (RFC 8446) removes all legacy cipher suites, reduces handshake round-trips to one, and mandates forward secrecy. There is no reason to support TLS 1.0 or 1.1 in 2026 — both were formally deprecated by RFC 8996 in 2021.

ssl_protocols TLSv1.2 TLSv1.3;

For TLS 1.2, restrict ciphers to AEAD suites only. TLS 1.3 ciphers are not configurable in Nginx — they are handled by OpenSSL and are already secure by default.

# TLS 1.2 ciphers only — TLS 1.3 ciphers are managed by OpenSSL
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';
ssl_prefer_server_ciphers off;

Note: Mozilla's "Intermediate" profile (2024 revision) sets ssl_prefer_server_ciphers off because all permitted ciphers are already strong. Forcing server preference adds no security and can hurt performance on constrained clients.

Verify:

# Confirm TLS 1.3 is negotiated
openssl s_client -connect yourdomain.here:443 -tls1_3 < /dev/null 2>&1 | grep 'Protocol\|Cipher'

# Confirm TLS 1.1 is rejected
openssl s_client -connect yourdomain.here:443 -tls1_1 < /dev/null 2>&1 | grep 'alert'

Common pitfall: Listing TLSv1.3 alone without TLS 1.2 will break clients that have not yet adopted 1.3 (some older Java HTTP clients, IoT devices). Keep TLS 1.2 unless you have full control over all connecting clients.

2. Enable HTTP/3 (QUIC)

Nginx 1.25+ includes native QUIC support (RFC 9000, RFC 9114). HTTP/3 runs over UDP, eliminates head-of-line blocking, and provides 0-RTT connection resumption. It requires TLS 1.3 only — there is no fallback to older protocol versions over QUIC.

# Existing HTTPS listener
listen 443 ssl;
http2 on;

# Add QUIC / HTTP/3 listener on the same port
listen 443 quic reuseport;

# Advertise HTTP/3 availability to clients via Alt-Svc header
add_header Alt-Svc 'h3=":443"; ma=86400' always;

Common pitfall: The reuseport parameter must appear on exactly one listen 443 quic directive across all server blocks. Duplicating it causes a bind failure on reload. If you have multiple server blocks on port 443, add reuseport only in the default server.

Ensure your firewall allows UDP on port 443:

sudo ufw allow 443/udp

Verify:

# Check Alt-Svc header is present
curl -sI https://yourdomain.here | grep -i alt-svc

# Test HTTP/3 with curl (requires curl 8.x+ built with HTTP/3 support)
curl --http3-only -I https://yourdomain.here

3. Enable HSTS (HTTP Strict Transport Security)

HSTS (RFC 6797) instructs browsers to only connect over HTTPS for the specified duration, preventing SSL-stripping attacks. Without it, the first request to your domain can be intercepted over plain HTTP.

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

The max-age=63072000 value equals two years — the minimum required for HSTS preload list submission. The always parameter ensures the header is sent even on error responses (e.g. 404, 500).

Warning: Adding includeSubDomains forces HTTPS on every subdomain. Verify that all subdomains have valid certificates before enabling this. Removing HSTS after deployment requires waiting out the max-age timer in every cached browser.

Once you have confirmed HSTS works correctly, consider submitting your domain to the browser HSTS preload list at hstspreload.org.

Verify:

curl -sI https://yourdomain.here | grep -i strict-transport-security

Common pitfall: Setting HSTS only on the location / block instead of the server block means error pages and redirects will not carry the header. Always set HSTS at the server level with the always flag.

4. Configure OCSP Stapling

Without OCSP stapling, the client's browser contacts the CA's OCSP responder directly to check certificate revocation status — adding latency and leaking browsing history to the CA. With stapling, Nginx fetches the OCSP response itself and includes it in the TLS handshake (RFC 6960, RFC 6066 section 8).

ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.here/chain.pem;

# Resolver for OCSP responder lookups — use your preferred DNS
# Quad9 (privacy-focused) or Cloudflare as alternatives to Google DNS
resolver 9.9.9.9 1.1.1.1 valid=300s;
resolver_timeout 5s;

The ssl_trusted_certificate must point to the CA's intermediate chain (not the full chain). For Let's Encrypt, use chain.pem.

Verify:

# Check OCSP stapling response
openssl s_client -connect yourdomain.here:443 -status < /dev/null 2>&1 | grep -A 2 'OCSP Response'

Expected output should include OCSP Response Status: successful. If you see OCSP response: no response sent, it means Nginx has not yet fetched the OCSP response.

Common pitfall: OCSP stapling does not work on the first request after an Nginx restart — the response is fetched lazily. You can prime it by making a request to your own server after reload: curl -s https://yourdomain.here > /dev/null. Also ensure that Nginx can resolve the OCSP responder hostname — if the resolver directive is missing, stapling silently fails.

5. TLS Session Handling

Session tickets and session IDs allow TLS session resumption, reducing handshake overhead. However, session tickets use a server-wide key that, if compromised, can decrypt past sessions — breaking forward secrecy.

# Use a shared session cache instead of tickets for forward secrecy
ssl_session_cache shared:TLS:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;

Note: Mozilla's "Intermediate" configuration recommends disabling session tickets. If you must use them (e.g. in a load-balanced cluster without shared memory), rotate the ticket key file at least every 24 hours and distribute it securely across nodes.

For TLS 1.3, 0-RTT (early data) further reduces latency but is vulnerable to replay attacks. Disable it unless your application is replay-safe:

ssl_early_data off;

6. HTTP-to-HTTPS Redirect

Use a separate server block for the redirect rather than an if inside the HTTPS block. The if directive inside a server/location context is evaluated on every request and is a well-known source of unexpected behavior in Nginx.

server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.here www.yourdomain.here;

    # Let's Encrypt challenge path
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

Common pitfall: Using $server_name instead of $host in the redirect target hardcodes the first server_name value, breaking redirects for alternate domain names (e.g. www vs bare domain).

7. Security Response Headers

These headers complement your TLS configuration by instructing browsers how to handle your content. Add them in the HTTPS server block:

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

# Clickjacking protection
add_header X-Frame-Options "SAMEORIGIN" always;

# Control referrer information leakage
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Restrict browser features (replaces Feature-Policy)
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

# Content Security Policy — tailor to your application
# Start with report-only mode to avoid breaking functionality
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;

Warning: A strict Content-Security-Policy can break third-party scripts, inline styles, and fonts. Always deploy with Content-Security-Policy-Report-Only first, review violation reports, and only then switch to enforcing mode.

Verify all headers:

curl -sI https://yourdomain.here | grep -iE 'strict-transport|x-content-type|x-frame|referrer-policy|permissions-policy|content-security'

8. Certificate and Key Best Practices

  • Use ECDSA P-256 certificates where possible — they are faster than RSA 2048 in TLS handshakes and produce smaller signatures. Let's Encrypt supports ECDSA via certbot --key-type ecdsa.
  • If you must use RSA, use a minimum of 2048-bit keys. RSA 4096 offers marginal security gain for significant performance cost.
  • Restrict private key file permissions: chmod 600, owned by root.
  • Automate renewal. Certbot's systemd timer (certbot.timer) runs twice daily by default — verify it is active.
# Request an ECDSA certificate from Let's Encrypt
sudo certbot certonly --nginx --key-type ecdsa --elliptic-curve secp256r1 -d yourdomain.here

# Verify key type of existing cert
openssl x509 -in /etc/letsencrypt/live/yourdomain.here/cert.pem -noout -text | grep 'Public Key Algorithm'

# Check renewal timer is active
systemctl status certbot.timer

Common pitfall: Forgetting the post-renewal hook. Certbot renews the certificate but Nginx continues serving the old one from memory until reloaded. Add a deploy hook: certbot renew --deploy-hook "nginx -t && systemctl reload nginx".

Complete Hardened Configuration

Here is the full Nginx server block incorporating all checklist items:

# HTTP — redirect all traffic to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name yourdomain.here www.yourdomain.here;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS — hardened server block
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;

    # HTTP/3 (QUIC)
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;

    server_name yourdomain.here www.yourdomain.here;
    root /var/www/html;
    index index.html;

    # --- TLS Protocol and Ciphers ---
    ssl_protocols TLSv1.2 TLSv1.3;
    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';
    ssl_prefer_server_ciphers off;

    # --- Certificates ---
    ssl_certificate /etc/letsencrypt/live/yourdomain.here/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.here/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/yourdomain.here/chain.pem;

    # --- Session Handling ---
    ssl_session_cache shared:TLS:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    ssl_early_data off;

    # --- OCSP Stapling ---
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 9.9.9.9 1.1.1.1 valid=300s;
    resolver_timeout 5s;

    # --- Security Headers ---
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # --- Content Security Policy (start with report-only) ---
    # add_header Content-Security-Policy "default-src 'self'" always;
    add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" always;
}

Verification Cheat Sheet

Run these after every configuration change:

# 1. Validate config syntax before reload
sudo nginx -t

# 2. Reload (not restart — avoids dropping active connections)
sudo systemctl reload nginx

# 3. Check negotiated protocol and cipher
openssl s_client -connect yourdomain.here:443 < /dev/null 2>&1 \
  | grep -E 'Protocol|Cipher|Verify'

# 4. Verify OCSP stapling is active
openssl s_client -connect yourdomain.here:443 -status < /dev/null 2>&1 \
  | grep 'OCSP Response Status'

# 5. Verify HSTS and security headers
curl -sI https://yourdomain.here | grep -iE 'strict|x-content|x-frame|referrer|permissions|alt-svc'

# 6. Test HTTP/3 (requires curl 8.x with HTTP/3)
curl --http3-only -sI https://yourdomain.here

# 7. Confirm old protocols are refused
openssl s_client -connect yourdomain.here:443 -tls1_1 < /dev/null 2>&1 | grep 'alert'

# 8. Check certificate expiry
openssl s_client -connect yourdomain.here:443 < /dev/null 2>&1 \
  | openssl x509 -noout -dates

For a comprehensive external audit, scan your domain with the Qualys SSL Labs test. An A+ grade requires all the above plus a valid HSTS header with a max-age of at least 6 months.

References

Conclusion

SSL/TLS hardening is not a one-time task — protocols evolve, cipher suites get deprecated, and new attack vectors emerge. The configuration in this checklist reflects 2026 best practices, but you should revisit it at least annually and re-scan with Qualys SSL Labs after every change.

The highest-impact items remain the simplest: enforce TLS 1.3, enable HSTS with preload, configure OCSP stapling, and automate certificate renewal. Get those right and you have eliminated the vast majority of real-world TLS attack surface.