TLS 1.3 Only: Hardening Nginx SSL to Get an A+ on SSL Labs

SSL Labs' A+ rating is the benchmark for a correctly hardened HTTPS configuration. Achieving it requires more than just installing a certificate — you need to restrict protocol versions, configure cipher suites appropriately, enable OCSP stapling, set strict HSTS headers, and tune session resumption. This article covers a complete Nginx TLS 1.3 configuration for a Drupal site, with explanations for every directive.

Why TLS 1.3 Only?

TLS 1.2 is still considered secure when properly configured, but "properly configured" is the problem. It requires carefully curated cipher suite lists to avoid weak ciphers, and many servers are misconfigured. TLS 1.3 removes that complexity: it has a fixed, small set of strong cipher suites and mandatory forward secrecy. There is no cipher negotiation to get wrong.

The practical question is browser support. TLS 1.3 is supported by all current browsers, including Chrome 70+, Firefox 63+, Safari 12.1+, and Edge 79+. Internet Explorer does not support TLS 1.3, but if you are still supporting IE in 2025 you have larger problems. For a modern Drupal site, TLS 1.3 only is a safe and correct choice.

Certificate Setup with Let's Encrypt

Certbot with the Nginx plugin handles certificate issuance and renewal. For an ECDSA certificate (preferred for TLS 1.3 performance):

certbot certonly --nginx \
  --key-type ecdsa \
  --elliptic-curve secp384r1 \
  -d example.com \
  -d www.example.com

ECDSA certificates are smaller than RSA, which reduces TLS handshake size and speeds up connections. The secp384r1 curve is widely supported and provides 192-bit security.

Core SSL Directives

Protocol and Cipher Configuration

ssl_protocols TLSv1.3;

# TLS 1.3 cipher suites are fixed by the spec.
# Nginx's ssl_ciphers directive does NOT apply to TLS 1.3.
# The following line is only relevant if you allow TLS 1.2 as fallback:
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...;

ssl_prefer_server_ciphers off;

With TLS 1.3 only, you do not need an ssl_ciphers directive at all. TLS 1.3 mandates three cipher suites (TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256) and the client and server negotiate from these automatically. Setting ssl_prefer_server_ciphers off lets the client choose the cipher, which is correct for TLS 1.3.

ECDH Curves

ssl_ecdh_curve X25519:secp384r1:secp256r1;

This configures the key exchange curves in preference order. X25519 is the fastest and most widely supported for TLS 1.3 key exchange. Including secp384r1 and secp256r1 as fallbacks ensures compatibility with all TLS 1.3 clients.

Certificate Files

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

Always use fullchain.pem as the certificate file, not cert.pem. The full chain includes intermediate certificates that clients need to validate your certificate against a trusted root.

OCSP Stapling

OCSP (Online Certificate Status Protocol) stapling allows the server to include a pre-fetched, CA-signed response in the TLS handshake, proving the certificate has not been revoked. This saves the client from making a separate OCSP request and speeds up connections.

ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;

Important note on Let's Encrypt and OCSP in 2025: Let's Encrypt announced in 2024 that they are phasing out their OCSP infrastructure. As of early 2025, Let's Encrypt certificates still support OCSP, but the infrastructure is being wound down in favour of Certificate Revocation Lists (CRLs) and short-lived certificates. The OCSP Must-Staple TLS extension (must-staple in Certbot) is particularly affected — if OCSP becomes unavailable and you have Must-Staple set, browsers will hard-fail on your certificate. For new configurations, avoid --must-staple in Certbot. Keep ssl_stapling on for now (it degrades gracefully if OCSP is unavailable), but monitor Let's Encrypt announcements for when to remove it.

Session Resumption

ssl_session_cache   shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;

Session caching stores session parameters server-side, allowing reconnecting clients to skip the full handshake. The 10m cache stores approximately 40,000 sessions. A 1-day timeout is reasonable for most sites.

Session tickets (ssl_session_tickets off) are disabled because they can compromise forward secrecy if the ticket encryption key is compromised. SSL Labs deducts points if session tickets are enabled with a key that is not rotated regularly. Disabling them entirely is the safe choice.

Note: TLS 1.3 has its own session resumption mechanism (PSK — Pre-Shared Keys) that is separate from TLS 1.2 session caching. The directives above primarily affect the TLS 1.2 fallback behaviour. For TLS 1.3 only configurations they have less impact but are worth setting correctly for completeness.

HSTS: HTTP Strict Transport Security

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

This tells browsers to only connect over HTTPS for the next two years (63072000 seconds). The includeSubDomains directive applies this policy to all subdomains. The preload directive signals eligibility for the HSTS preload list.

Before setting includeSubDomains and preload: be absolutely certain that every subdomain of your domain serves HTTPS. If any subdomain only serves HTTP, setting includeSubDomains will break it for users who have visited your site before. Start with a short max-age (e.g., max-age=300) while testing, then increase it once you are confident.

To submit your domain to the HSTS preload list, visit hstspreload.org. Once listed, browsers will enforce HTTPS even on the very first visit, before any HTTP response has been received.

Additional Security Headers

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-XSS-Protection "0" always;

Note that X-XSS-Protection: 0 is now the recommended value. The old 1; mode=block value could actually introduce XSS vulnerabilities in some scenarios. Modern browsers rely on CSP instead.

Complete Server Block for a Drupal Site

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

# Redirect www to non-www over HTTPS
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name www.example.com;

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

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

# Main HTTPS server
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com;

    root /var/www/drupal/web;
    index index.php;

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

    # Protocol — TLS 1.3 only
    ssl_protocols TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_ecdh_curve X25519:secp384r1:secp256r1;

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

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header X-XSS-Protection "0" always;

    # Drupal location blocks
    location / {
        try_files $uri /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass   unix:/run/php/php8.3-fpm.sock;
        fastcgi_index  index.php;
        include        fastcgi_params;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Deny access to sensitive Drupal files
    location ~* \.(engine|inc|install|make|module|profile|po|sh|.*sql|theme|twig|tpl(\.php)?|xtmpl|yml)(~|\.sw[op]|\.bak|\.orig|\.save)?$|^(\..*|Entries.*|Repository|Root|Tag|Template|composer\.(json|lock)|web\.config)$|^#.*#$|\.php(~|\.sw[op]|\.bak|\.orig|\.save)$ {
        return 404;
    }
}

Testing with SSL Labs

Submit your domain at ssllabs.com/ssltest/. An A+ rating requires:

  • No weak protocols (TLS 1.0, 1.1 disabled)
  • No weak cipher suites
  • HSTS with a max-age of at least one year
  • No known vulnerabilities (BEAST, POODLE, Heartbleed, ROBOT, etc.)

Testing with openssl s_client

# Test TLS 1.3 connection
openssl s_client -connect example.com:443 -tls1_3

# Verify the protocol negotiated
openssl s_client -connect example.com:443 2>/dev/null | grep "Protocol"

# Check OCSP stapling
openssl s_client -connect example.com:443 -status 2>/dev/null | grep -A 10 "OCSP"

# Attempt TLS 1.2 connection (should fail with TLS 1.3 only config)
openssl s_client -connect example.com:443 -tls1_2
# Expected: handshake failure

Nginx Version Requirements

TLS 1.3 support in Nginx requires OpenSSL 1.1.1 or later, which ships with Ubuntu 18.04+ and Debian 10+. Verify with:

nginx -V 2>&1 | grep -o 'OpenSSL [0-9.a-z]*'
openssl version

If you are on an older system with OpenSSL 1.0.x, you will need to either upgrade the OS or compile Nginx against a newer OpenSSL version. Most modern Linux distributions have this covered out of the box.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.