Why TLS Configuration Matters
A weak TLS configuration can expose your users to serious attacks even when using HTTPS. Outdated protocols and cipher suites allow:
- BEAST, POODLE, CRIME - Attacks on TLS 1.0 and older protocols
- DROWN - Attack exploiting SSLv2 support
- Logjam - Weak Diffie-Hellman parameters
- Sweet32 - Birthday attacks on 64-bit block ciphers
- Man-in-the-Middle - Decryption of traffic using weak ciphers
Protocol Version Guide
| Protocol | Status | Recommendation |
|---|---|---|
| SSLv2 | Broken | Never enable - DROWN attack |
| SSLv3 | Broken | Never enable - POODLE attack |
| TLS 1.0 | Deprecated | Disable - BEAST, multiple vulnerabilities |
| TLS 1.1 | Deprecated | Disable - No longer considered secure |
| TLS 1.2 | Secure | Enable with strong ciphers only |
| TLS 1.3 | Recommended | Enable - Fastest and most secure |
Modern Secure Configuration
Here's a production-ready TLS configuration:
# Modern TLS configuration (2026)
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Session handling
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# Key exchange groups: leave nginx's default unless you have a specific
# reason. Naming a group your OpenSSL does not know is a startup failure.
ssl_ecdh_curve auto;
resolver. If your certificate comes from Let's Encrypt, stapling does
nothing at all any more — see OCSP Stapling below.
And when you do need a resolver, point it at a local one: an external resolver like
1.1.1.1 or 8.8.8.8 lets an off-path attacker spoof DNS
replies into nginx's cache, which is why Gixy's
resolver_external
check rates it HIGH.
TLS 1.3 Only (Maximum Security)
If you can drop TLS 1.2 support (most modern clients support 1.3):
# TLS 1.3 only - maximum security
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers off;
# TLS 1.3 has built-in secure ciphers
# No need to specify ssl_ciphers
Insecure Patterns to Avoid
Outdated Protocols
# NEVER use these protocols
ssl_protocols SSLv2 SSLv3 TLSv1 TLSv1.1;
# These are vulnerable to POODLE, BEAST, and other attacks
Weak Cipher Suites
# NEVER use these ciphers
ssl_ciphers RC4:DES:3DES:MD5:EXPORT:NULL:ADH;
# RC4 - Broken stream cipher
# DES/3DES - Weak block ciphers (Sweet32)
# MD5 - Broken hash function
# EXPORT - Intentionally weak ciphers
# NULL - No encryption!
# ADH - No authentication
Certificate Configuration
server {
listen 443 ssl;
http2 on;
server_name example.com;
# Certificate and private key
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Diffie-Hellman parameters, only used by the DHE ciphers in TLS 1.2
ssl_dhparam /etc/nginx/ffdhe2048.pem;
# ... rest of configuration
}
Note there is no ssl_trusted_certificate. Its only common purpose was
OCSP stapling verification, and for a Let's Encrypt certificate there is nothing
left to staple.
Diffie-Hellman Parameters: Use ffdhe2048, Don't Generate Your Own
The old advice was openssl dhparam 4096. That is no longer the
recommendation. Mozilla ships pre-generated, standardised parameters
(RFC 7919's ffdhe2048
group), and using them is both safer and instant:
# Use Mozilla's pre-generated RFC 7919 ffdhe2048 group
curl -o /etc/nginx/ffdhe2048.pem https://ssl-config.mozilla.org/ffdhe2048.txt
Why not roll your own 4096-bit group?
Logjam was about weak (512/1024-bit) and backdoorable groups,
not about shared ones. A published, well-scrutinised 2048-bit group has no known
weakness, whereas a locally generated one is unverified — you have no way to
confirm your own dhparam output is a safe prime with no hidden
structure. 4096-bit DHE also costs measurable handshake CPU for no practical
security gain over 2048. If you serve TLS 1.3 only, ssl_dhparam is
irrelevant entirely: TLS 1.3 dropped finite-field DHE from its cipher suites.
OCSP Stapling (and Why Let's Encrypt Users Should Delete It)
OCSP stapling has your server fetch the certificate's revocation status from the CA and hand it to clients during the handshake, instead of each client querying the CA itself. It saves a round trip and stops the CA from seeing who visits your site.
Let's Encrypt no longer has an OCSP responder
Let's Encrypt stopped including OCSP URLs in its certificates in early 2025 and
shut its OCSP responders down on 2025-08-06. nginx staples by
reading the responder URL out of your certificate — with no URL there is
nothing to fetch, so ssl_stapling on; for a certbot certificate is a
permanent no-op. It is not dangerous, just dead config that produces log noise and
misleads the next person who reads it. Delete ssl_stapling,
ssl_stapling_verify, and any ssl_trusted_certificate that
existed only to serve them. Revocation for Let's Encrypt is handled by short
certificate lifetimes and by CRLs (CRLite, CRLSets) instead.
Check any certificate for yourself:
openssl x509 -in cert.pem -noout -ocsp_uri — empty output means
there is nothing to staple.
Stapling is still worth configuring if your certificate comes from a commercial CA that
runs an OCSP responder. In that case nginx needs a resolver to look up the
responder hostname — and it should be a local one:
# Only useful when the certificate actually carries an OCSP URL
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/certs/example.com.chain.pem;
# Local resolver — never 1.1.1.1 / 8.8.8.8, see the resolver_external check
resolver 127.0.0.1 valid=300s;
resolver_timeout 5s;
Gixy flags both halves of this: ssl_stapling_letsencrypt when stapling is enabled for a Let's Encrypt path, and ssl_stapling_without_resolver when stapling is on but no resolver is reachable.
Key Exchange Groups and Post-Quantum TLS
ssl_ecdh_curve controls which key exchange groups nginx offers. It is the
single most dangerous directive in a modern TLS config, because nginx passes it
straight to OpenSSL's SSL_CTX_set1_curves_list() and treats a parse
failure as NGX_LOG_EMERG — the master process does not
start.
OpenSSL rejects the entire list if one group name is unknown to the linked library. So the line that every post-quantum blog post is currently recommending:
# Takes the site DOWN on Debian 12, Ubuntu 24.04, RHEL 9
ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1;
…does not "fall back to X25519" on a box with older OpenSSL. It refuses to
start. X25519MLKEM768 and the other ML-KEM hybrids arrived in
OpenSSL 3.5; Debian 12 and Ubuntu 24.04 LTS ship OpenSSL 3.0, and
RHEL/AlmaLinux/Rocky 9 ships 3.2. The same applies to the older
X25519Kyber768Draft00 draft names.
The survivable form prefixes unknown-tolerant groups with ?:
# OpenSSL 3.3+ skips a `?`-prefixed group it does not recognise
ssl_ecdh_curve ?X25519MLKEM768:X25519:prime256v1;
Except that ? itself was only added in OpenSSL 3.3. On
OpenSSL 3.0 and 3.2 the prefixed name is just as unparseable, and nginx still refuses
to start. So:
- OpenSSL 3.5+ — name post-quantum groups directly, or with
?. - OpenSSL 3.3 / 3.4 — use
?so the config is forward-compatible. - OpenSSL 3.0 / 3.2 — do not name them at all. Leave
ssl_ecdh_curve auto;.
Check what you actually have with openssl version and
openssl list -tls-groups before touching this directive. Gixy's
ssl_ecdh_curve
check flags unprefixed post-quantum groups as HIGH for exactly this reason.
HTTP/2 and HTTP/3
# Enable HTTP/2 (nginx 1.25.1+ — the `listen ... http2` parameter is deprecated)
listen 443 ssl;
http2 on;
# Enable HTTP/3 (QUIC) - NGINX 1.25+
listen 443 quic reuseport;
listen 443 ssl;
# Advertise HTTP/3 support
add_header Alt-Svc 'h3=":443"; ma=86400';
Complete Server Block Example
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name example.com;
# Certificates (Let's Encrypt — no ssl_trusted_certificate needed,
# there is no OCSP responder left to verify against)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Protocols 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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Key exchange groups — the default is the safe choice
ssl_ecdh_curve auto;
# DH parameters (Mozilla's RFC 7919 ffdhe2048, used only by the DHE ciphers)
ssl_dhparam /etc/nginx/ffdhe2048.pem;
# Session handling
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# No ssl_stapling: Let's Encrypt retired OCSP on 2025-08-06
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
root /var/www/example.com;
index index.html;
}
Every snippet on this page is checked with Gixy before publication — the configuration above produces zero findings.
Detecting Issues with Gixy
Gixy automatically detects weak SSL/TLS configurations:
$ gixy /etc/nginx/conf.d/ssl.conf
==================== Results ===================
>> Problem: [weak_ssl_tls] Insecure TLS protocols enabled
Severity: HIGH
Description: Using outdated TLS protocols (TLSv1.0, TLSv1.1) or weak cipher suites exposes your server to attacks such as POODLE, BEAST, and SWEET32. Modern configurations should use TLSv1.2+ with strong AEAD ciphers.
Additional info: https://gixy.getpagespeed.com/checks/weak-ssl-tls/
Reason: Insecure protocols enabled: TLSv1, TLSv1.1. TLSv1.0 and TLSv1.1 are vulnerable to POODLE, BEAST, and other attacks.
Pseudo config:
server {
server_name example.com;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
}
------------------------------------------------
>> Problem: [ssl_ecdh_curve] ssl_ecdh_curve names a post-quantum group that may prevent nginx from starting
Severity: HIGH
Additional info: https://gixy.getpagespeed.com/checks/ssl-ecdh-curve/
Reason: Group(s) X25519MLKEM768 are post-quantum hybrids that only exist in OpenSSL 3.5+. If the running OpenSSL does not know one of them, SSL_CTX_set1_curves_list() rejects the entire list, nginx logs it as an emergency and the master process fails to start — the site goes down, it does not merely lose post-quantum support.
Pseudo config:
server {
server_name example.com;
ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1;
}
------------------------------------------------
>> Problem: [weak_ssl_tls] Server cipher preference enabled unnecessarily
Severity: LOW
Additional info: https://gixy.getpagespeed.com/checks/weak-ssl-tls/
Reason: ssl_prefer_server_ciphers is on, forcing server cipher order. With modern cipher lists (all strong AEAD ciphers), client cipher preference improves performance — mobile clients without AES-NI benefit from choosing ChaCha20-Poly1305 over AES-GCM. Mozilla and nginx maintainers recommend off. If you must keep server preference (policy, compliance), add `ssl_conf_command Options PrioritizeChaCha;` so the server order still defers to clients that put ChaCha20-Poly1305 first.
Pseudo config:
server {
server_name example.com;
ssl_prefer_server_ciphers on;
}
==================== Summary ===================
Total issues:
Unspecified: 0
Low: 1
Medium: 0
High: 2
(Descriptions trimmed for length; the real output repeats the full check description under each finding.)
Testing Your Configuration
# Test with OpenSSL
openssl s_client -connect example.com:443 -tls1_3
# Check supported protocols
nmap --script ssl-enum-ciphers -p 443 example.com
# Online testing tools:
# - ssllabs.com/ssltest/
# - testssl.sh
SSL/TLS Checklist
- Disable SSLv2, SSLv3, TLS 1.0, and TLS 1.1
- Enable TLS 1.2 and TLS 1.3
- Use only AEAD cipher suites (GCM, ChaCha20-Poly1305)
- Use Mozilla's
ffdhe2048rather than generating your own DH parameters - Leave
ssl_ecdh_curveatautounless your OpenSSL is 3.5+ - Enable OCSP stapling only for a CA that still runs a responder — not Let's Encrypt
- Point any
resolverat a local address, never1.1.1.1or8.8.8.8 - Configure HSTS header
- Set up automatic certificate renewal
- Test with SSL Labs (aim for A+ grade)
Further Reading
- Gixy Weak SSL/TLS Detection
- Gixy Post-Quantum ssl_ecdh_curve Check
- Gixy OCSP Stapling With Let's Encrypt Check
- Gixy External Resolver Check
- Let's Encrypt: Intent to End OCSP Service
- Security Headers Guide
- NGINX Hardening Checklist
- Mozilla SSL Configuration Generator
- ssl_stapling_without_resolver: stapling that silently does nothing