1
Defense INTERMEDIATE 15 min read Updated

TLS Hardening: Ciphers, Protocols and Certificate Hygiene

TLS hardening in practice: drop TLS 1.0 and 1.1, keep 1.2 and 1.3, pick AEAD ciphers with forward secrecy, keep certificates clean, and prove the result with testssl.sh and openssl.

founder & security researcher · 39 writeups
TLS Hardening: Ciphers, Protocols and Certificate Hygiene

TLS hardening is the work of trimming a server back to the protocols, ciphers and certificate settings that are still safe, and removing the ones that linger only because nobody turned them off. A fresh web server ships with defaults aimed at compatibility, so it will happily speak to a decade-old client using a cipher that was broken years ago. Hardening reverses that bias: it says no to the weak options first, and keeps the handful that hold up. This guide walks the whole surface, protocol versions, cipher suites and key exchange, certificate hygiene, HSTS and OCSP stapling, then shows a worked nginx config and how to verify it.

Read it top to bottom once to see how the pieces fit, then keep it as a checklist you run against each host. Every command and config here targets a host you own or are authorized to assess.

Scope

Test and apply these settings only on a server you operate or have written permission to change, such as the lab host lab.0xpriv.dev. Scanning or reconfiguring systems you do not control is out of bounds.

01Why TLS configuration drifts and what hardening achieves

TLS configuration drifts because the defaults optimize for reach, not safety, and because old settings outlive the clients they were meant to support. A server set up three years ago kept TLS 1.0 enabled for some legacy integration, the integration was retired, and the protocol stayed. Package upgrades add new cipher suites but rarely remove old ones. Teams copy a config from an older box because it worked. The result is a host that still negotiates protocols and ciphers with known weaknesses, long after any real client needed them.

Hardening achieves three things at once. It shrinks the attack surface, so a downgrade or a cipher weakness has nothing to grab. It makes the connection honest about what it protects, so you are not relying on a cipher that leaks under analysis. And it keeps you aligned with what browsers and compliance baselines expect, which matters the day a scanner flags the host. The aim is not the highest score on a test page, it is a configuration you can defend and that still serves the clients you actually have.

02Protocol versions: disable TLS 1.0 and 1.1, keep 1.2 and 1.3

Protocol version selection is the first lever, and the rule is short: turn off SSL 3.0, TLS 1.0 and TLS 1.1, and serve only TLS 1.2 and TLS 1.3. TLS 1.0 dates to 1999 and TLS 1.1 to 2006; both were formally deprecated by RFC 8996 in 2021, and every current browser dropped them in 2020. They carry weaknesses the newer versions fixed, including the CBC padding issues behind BEAST and the downgrade exposure that POODLE abused in SSL 3.0. Leaving them enabled gives an attacker a downgrade target and gives you nothing.

nginx
# only the two modern versions; everything older is refused
ssl_protocols TLSv1.2 TLSv1.3;

The table below is the quick reference for what to serve and what to refuse.

VersionStatusAction
SSL 2.0 / 3.0Broken (DROWN, POODLE)Disable
TLS 1.0 / 1.1Deprecated (RFC 8996)Disable
TLS 1.2Secure when configured wellEnable
TLS 1.3Current, safest defaultEnable
Check for legacy clients first

Before you cut TLS 1.0 and 1.1 on a shared host, confirm nothing real still needs them. Old payment terminals, embedded devices and some automated jobs can be stuck on TLS 1.0. Grep the access logs for the negotiated version, or watch handshake failures for a week, before you flip it in production.

03Why TLS 1.3 is simpler and safer

TLS 1.3 is both simpler and safer because it removed the dangerous options instead of leaving them as configurable choices. Where TLS 1.2 lets you assemble a cipher suite from parts, some of them weak, TLS 1.3 ships a tiny fixed menu of AEAD suites and nothing else. It cut RSA key exchange, static Diffie-Hellman, the CBC modes, RC4, 3DES, MD5, SHA-1, renegotiation and compression. Forward secrecy is no longer optional, it is built in, because every handshake uses an ephemeral key exchange.

The handshake itself is faster. TLS 1.2 needs two round trips before application data flows; TLS 1.3 needs one, because the client guesses the key exchange group and sends its share in the first message. Fewer knobs means fewer ways to misconfigure, which is the real security win: a TLS 1.3 server is hard to set up badly.

handshake round trips: TLS 1.2 vs TLS 1.3
TLS 1.2 (2-RTT) ClientHello ServerHello, cert,key exchange Finished, thenapp data TLS 1.3 (1-RTT) ClientHello + key share(group guessed) ServerHello, cert, Finishedapp data next flight

04Cipher suites and key exchange: AEAD and forward secrecy

Cipher suite selection decides how the bytes are actually protected, and the two properties that matter are AEAD and forward secrecy. AEAD (authenticated encryption with associated data) ties encryption and integrity into one operation, which closes the padding-oracle class of attacks that haunted the older CBC modes. Forward secrecy, provided by an ephemeral key exchange such as ECDHE, means a server private key stolen next year cannot decrypt traffic captured today, because each session used a throwaway key.

So the keep list is short: AES-GCM and ChaCha20-Poly1305 for the cipher, ECDHE (or DHE) for the key exchange. The drop list is longer, and every item on it should be gone: RC4 (biased keystream), 3DES (64-bit block, Sweet32), export and NULL suites, anything with CBC on legacy protocols, and plain RSA key exchange, which has no forward secrecy.

nginx
# TLS 1.2 suites: ECDHE key exchange, AEAD ciphers only
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;
# let the client pick between AES-GCM and ChaCha20 (better for mobile)
ssl_prefer_server_ciphers off;

TLS 1.3 needs no cipher list from you in normal use. Its suites (TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256) are all AEAD and all forward-secret by design, so the safe set is the default set. If you want to pin them in nginx built on OpenSSL 1.1.1 or newer, use ssl_conf_command Ciphersuites, which is separate from ssl_ciphers.

ChaCha20 earns its place

Keep ChaCha20-Poly1305 in the list even though AES-GCM is faster on server hardware. Phones without AES acceleration run ChaCha20 far quicker, and with ssl_prefer_server_ciphers off each client picks whichever is best for it.

05Certificate hygiene: keys, SANs and chain order

Certificate hygiene is the half of TLS that configuration guides often skip, yet a clean cipher list is worthless behind a certificate that clients reject. Start with the key. Use RSA 2048 as the floor, 3072 if policy demands it, or better still an ECDSA P-256 key, which is smaller and faster for the same strength. Generate the key and a signing request that names every hostname in the subjectAltName field, because modern clients ignore the old Common Name entirely.

bash
# ECDSA P-256 key and a CSR with SANs (CN alone is ignored by clients)
openssl ecparam -genkey -name prime256v1 -out lab.0xpriv.dev.key
openssl req -new -key lab.0xpriv.dev.key -out lab.0xpriv.dev.csr \
  -subj "/CN=lab.0xpriv.dev" \
  -addext "subjectAltName=DNS:lab.0xpriv.dev,DNS:www.lab.0xpriv.dev"

The other classic foot-gun is chain order. The server must send the leaf certificate first, then each intermediate in order up toward (but not including) the root, which clients already trust. A missing intermediate or a scrambled chain works in some browsers (they cache intermediates) and fails in others, which produces the maddening "works for me" bug report. Point nginx at a fullchain file, leaf then intermediates, and verify it.

confirm chain order and expiry
$ openssl s_client -connect lab.0xpriv.dev:443 -servername lab.0xpriv.dev < /dev/null 2>/dev/null | openssl x509 -noout -dates -subject
notBefore=Sep 12 00:00:00 2026 GMT
notAfter=Dec 11 23:59:59 2026 GMT
subject=CN=lab.0xpriv.dev
$ openssl s_client -connect lab.0xpriv.dev:443 -showcerts < /dev/null 2>/dev/null | grep -c "BEGIN CERTIFICATE"
2

06Automating renewal with ACME and Let's Encrypt

Automated renewal is the single change that prevents the most common TLS outage: an expired certificate nobody was watching. ACME is the protocol behind Let's Encrypt and most other free CAs, and a client like certbot or acme.sh proves you control the domain, fetches the certificate, installs it and schedules renewal. Certificates last 90 days, which is deliberate: short lifetimes force automation, and automation means expiry stops being a human responsibility.

bash
# 0xpriv.dev/defense
# issue for the host, then let the timer renew it unattended
certbot certonly --nginx -d lab.0xpriv.dev -d www.lab.0xpriv.dev

# certbot installs a systemd timer; this is the dry run that proves it works
certbot renew --dry-run

# reload nginx only after a successful renewal
certbot renew --deploy-hook "systemctl reload nginx"

The one thing automation does not remove is the need to watch. A renewal can fail silently if DNS changes, a port is firewalled, or a rate limit hits, so pair the timer with an external expiry check that alerts you well before the 90 days run out.

07HSTS and OCSP stapling

HSTS and OCSP stapling are the two headers-and-handshake extras that turn a working TLS setup into a hard one. HTTP Strict Transport Security (HSTS) tells the browser to only ever reach this host over HTTPS, which kills the downgrade-to-HTTP window an attacker needs for SSL stripping. Send it only once you are sure every subdomain can serve HTTPS, because includeSubDomains and especially preload are hard to walk back.

nginx
# two years, cover subdomains, and opt into the preload list
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# OCSP stapling: the server fetches the revocation status so the client does not have to
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/lab.0xpriv.dev/chain.pem;
resolver 1.1.1.1 9.9.9.9 valid=300s;
resolver_timeout 5s;

OCSP stapling lets the server attach a signed, time-limited proof that its certificate is not revoked, so the client skips a slow, privacy-leaking call to the CA. It is worth enabling where your CA still supports it, though the ground is shifting: Let's Encrypt has announced it is winding down OCSP in favor of short-lived certificates and CRLs, so treat stapling as a current nicety rather than a permanent fixture.

HSTS is sticky

Once a browser sees an HSTS header it enforces HTTPS for the full max-age, even if you later break TLS. Submitting to the preload list is close to permanent and slow to undo. Start with a short max-age, confirm nothing breaks, then raise it and add preload.

08The Mozilla modern and intermediate profiles as a baseline

The Mozilla profiles give you a vetted starting point so you are not hand-picking cipher strings from memory. Mozilla publishes three: Modern, Intermediate and Old, each with a protocol set and cipher list that their team keeps current. Pick one as your baseline and adjust only with a reason.

ProfileProtocolsUse when
ModernTLS 1.3 onlyYou control the clients and none are old
IntermediateTLS 1.2 and 1.3General-purpose public site (the sane default)
OldBack to TLS 1.0Only if you must serve very old clients

For a public web server the Intermediate profile is the right call: it drops everything deprecated while still answering any client from the last decade. The Mozilla SSL Configuration Generator prints a ready config for your exact server and version, which beats copying a stale snippet off a forum. Treat its output as the baseline and layer your HSTS and certificate paths on top.

09Testing with testssl.sh and openssl s_client

Testing is how you prove the hardening took, because a config that looks right can still negotiate something you forgot about. Two tools cover it. testssl.sh is a single shell script that connects to a host and reports every protocol, cipher and known weakness it finds. openssl s_client is the precise instrument for checking one thing at a time, such as whether an old protocol is truly refused.

bash
# full sweep: protocols, ciphers, certificate, known vulnerabilities
testssl.sh https://lab.0xpriv.dev

# narrow runs when you want a fast answer
testssl.sh --protocols lab.0xpriv.dev
testssl.sh -U lab.0xpriv.dev            # all vulnerability checks
testssl.sh: the protocol summary you want to see
$ testssl.sh --protocols lab.0xpriv.dev
SSLv2      not offered (OK)
SSLv3      not offered (OK)
TLS 1      not offered (OK)
TLS 1.1    not offered (OK)
TLS 1.2    offered (OK)
TLS 1.3    offered (OK): final

For a surgical check, point openssl s_client at the host and force an old protocol. A hardened server refuses the handshake, which is exactly the failure you want to see.

confirming TLS 1.0 is refused
$ openssl s_client -connect lab.0xpriv.dev:443 -tls1 < /dev/null
CONNECTED(00000003)
...:SSL alert number 70
no peer certificate available
No client certificate CA names sent
$ openssl s_client -connect lab.0xpriv.dev:443 -tls1_3 -servername lab.0xpriv.dev < /dev/null 2>/dev/null | grep "Protocol"
    Protocol  : TLSv1.3

10A hardened nginx server block

A worked nginx server block ties every setting above into one place you can copy and adapt. This follows the Mozilla Intermediate shape: two protocols, AEAD ciphers, client cipher preference, HSTS, OCSP stapling and ACME-managed certificates. Swap the hostname and paths for your own.

nginx
# hardened TLS server block, Mozilla Intermediate baseline
# 0xpriv.dev/defense
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name lab.0xpriv.dev;

    ssl_certificate     /etc/letsencrypt/live/lab.0xpriv.dev/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/lab.0xpriv.dev/privkey.pem;

    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;

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

    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/lab.0xpriv.dev/chain.pem;
    resolver 1.1.1.1 9.9.9.9 valid=300s;
    resolver_timeout 5s;

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

# plain HTTP exists only to redirect to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name lab.0xpriv.dev;
    return 301 https://$host$request_uri;
}

The DHE suites in the cipher line need Diffie-Hellman parameters. Use a named group with ssl_ecdh_curve or generate a 2048-bit ssl_dhparam file; never ship a 1024-bit one. After any change, run nginx -t before you reload, and test the live host again rather than trusting the config alone.

Set protocols and ciphers

Pin TLS 1.2 and 1.3 with the AEAD cipher list, then reload and confirm old protocols are refused.

Fix the certificate

ECDSA P-256 or RSA 2048+ key, SANs for every hostname, correct chain order, automated renewal.

Add HSTS and stapling

Short HSTS max-age first, raise it once stable; enable OCSP stapling where the CA supports it.

Verify from outside

Run testssl.sh and a couple of openssl s_client checks, and re-run them after every future change.

11Monitoring drift and certificate expiry

Monitoring is what keeps a hardened host hardened, because configuration rots quietly. A package upgrade can re-enable a protocol, a teammate can paste an old cipher line, and a certificate renewal can fail without anyone noticing until the browser error arrives. Watch for both: schedule a periodic external scan (testssl.sh in a cron job, or a scheduled SSL Labs run) and diff the result against a known-good baseline, so a re-enabled TLS 1.0 or a dropped cipher surfaces as an alert rather than a surprise.

For expiry, check the certificate from the client side, not just the file on disk, since a stale file can linger after a bad renewal. A one-line openssl probe that compares notAfter against a threshold, run daily and wired to your alerting, catches the failed renewal days early. The detection signal that matters here is any gap between what you shipped and what the host now serves.

12Shutting it down: the hardening checklist

Shutting down weak TLS comes down to a short set of controls applied in order, each one verifiable from outside the box. Treat the list below as the definition of done for a hardened host.

TLS hardening checklist
Serve only TLS 1.2 and 1.3. Disable SSL 3.0, TLS 1.0 and 1.1, and confirm with openssl that each old version is refused.
AEAD ciphers with forward secrecy only. ECDHE/DHE key exchange, AES-GCM and ChaCha20-Poly1305; drop RC4, 3DES, CBC-legacy and plain RSA.
Clean certificate. ECDSA P-256 or RSA 2048+, SANs for every name, leaf-then-intermediate chain order, automated ACME renewal.
HSTS and stapling. Add HSTS (short max-age first, then raise and preload) and enable OCSP stapling while your CA supports it.
Baseline from Mozilla, then verify. Start from the Intermediate profile and prove the live result with testssl.sh on a schedule.

13FAQ

Should I enable TLS 1.3 only, or keep TLS 1.2 as well?FAQ

For a public site, keep both (the Mozilla Intermediate profile). TLS 1.3 only (Modern) is the stronger stance, but it locks out any client that cannot speak 1.3, which still includes some older corporate browsers and automated tools. Go TLS 1.3 only when you control every client that connects.

Do I still need to set ssl_ciphers for TLS 1.3?FAQ

No. The ssl_ciphers directive controls TLS 1.2 and below. TLS 1.3 has its own small set of AEAD suites, all safe, and nginx uses sensible defaults for them. If you must pin the 1.3 suites, use ssl_conf_command Ciphersuites on OpenSSL 1.1.1 or newer, not ssl_ciphers.

Is OCSP stapling still worth enabling?FAQ

Enable it where your CA supports it, since it speeds up the handshake and keeps the client from phoning the CA. That said, the industry is moving away from OCSP: Let's Encrypt has announced it is ending OCSP in favor of CRLs and short-lived certificates, so stapling is becoming less central over time.

What max-age should I use for HSTS?FAQ

Start small, such as a few minutes or a day, to confirm nothing breaks over HTTPS. Then raise it to two years (max-age=63072000) and add includeSubDomains. Add preload and submit to the preload list only when you are certain every subdomain serves HTTPS, because it is very hard to reverse.

testssl.sh gives an A but a scanner still complains. Why?FAQ

Different tools weigh things differently and run at different times. A common cause is drift: the host changed since the last good scan, or the tool checks something testssl.sh does not, such as a weak DH parameter file or a chain missing an intermediate. Re-run both against the live host and compare the specific findings rather than the letter grade.

references

Was this writeup useful?
Alex Rowan Founder & Security Researcher at 0xpriv

Founder of 0xpriv, covering offensive security, web application vulnerabilities, and practical security research through PHP, Python, and hands-on technical analysis.

related writeups

more in Defense →

discussion

0 comments
`code` supported · comments are reviewed before they appear