TLS Cipher Suites: Hardening and Configuration That Actually Holds Up

Cipher suite discussions tend to become abstract fast. You start with a simple question like “what should I put in my Nginx config?” and twenty minutes later you are reading about key exchange, bulk ciphers, MAC construction, AEAD, and browser compatibility from a table last updated three years ago.

What helped me was treating cipher suites as an operational problem instead of a theory exam. I need to know what a suite does, which parts still matter in TLS 1.2, what changed in TLS 1.3, what to remove without hesitation, and how to verify that the server is negotiating what I intended.

This guide is the practical version. It is built around the commands and configuration checks I actually use when hardening public web servers.

What a cipher suite really describes

In TLS 1.2 and earlier, a cipher suite names a combination of building blocks:

  • key exchange: how shared secrets are established
  • authentication: how the server proves its identity
  • bulk encryption: how application data is encrypted
  • message integrity: how tampering is detected

A suite like this:

ECDHE-RSA-AES256-GCM-SHA384

roughly means:

  • ECDHE: ephemeral elliptic-curve Diffie-Hellman key exchange
  • RSA: the certificate/key type used for authentication
  • AES256-GCM: bulk cipher in AEAD mode
  • SHA384: PRF/hash association for the suite

That naming leads people to think the last SHA384 is a classic “MAC” in every case. In AEAD suites such as AES-GCM, integrity protection is built into the mode rather than handled as the older MAC-then-encrypt construction used with CBC suites.

For background, it helps to understand what happens during the TLS handshake and what changed from TLS 1.2 to TLS 1.3.

ECDHE vs RSA key exchange: why forward secrecy matters

This is one of the easiest hardening wins.

Older TLS deployments often used RSA key exchange suites. In those, the client encrypts a pre-master secret with the server’s RSA public key. If the server’s private key is later compromised and someone captured the traffic, those past sessions may be decrypted.

With ECDHE suites, each handshake creates an ephemeral shared secret. That gives you forward secrecy. Even if the long-term private key is stolen later, previously captured traffic remains much harder to decrypt because the ephemeral session secrets are gone.

When I audit a TLS config, one of the first things I check is whether non-forward-secret RSA key exchange is still available. In 2026, on a general public HTTPS service, I do not consider keeping it around a good default.

List suites and filter by key exchange style:

openssl ciphers -v 'ALL:@SECLEVEL=0' | grep -E 'ECDHE|DHE|RSA'

If you see plain TLS_RSA_* style support on a modern endpoint, that is usually a candidate for removal unless you have a very specific compatibility requirement.

AES-GCM vs CBC: the old world and the current one

If you have managed HTTPS long enough, you probably remember the years when avoiding CBC pitfalls was a constant topic.

CBC suites

CBC-based suites were normal for a long time:

ECDHE-RSA-AES256-SHA
ECDHE-RSA-AES128-SHA256

They combine a block cipher mode with a separate MAC. The older ecosystem around CBC accumulated a lot of history: BEAST, Lucky13, padding oracle discussions, odd library behavior, and endless compatibility caveats.

CBC is not automatically broken in every implementation today, but I do not prefer it when better options are available.

AEAD suites

Modern hardened configs should strongly favor AEAD suites such as:

  • AES-GCM
  • ChaCha20-Poly1305

These are simpler to reason about operationally and avoid the MAC-then-encrypt design baggage that made TLS harder to secure in the past.

For most deployments, if you are still prioritizing CBC instead of GCM or ChaCha20, you are carrying compatibility debt you may no longer need.

SHA, HMAC-SHA2, and what the names can mislead you about

People see SHA in suite names and draw the wrong conclusion.

In older suites, names like SHA or SHA256 often relate to the handshake PRF and MAC combination. In AEAD suites, integrity is part of the AEAD construction, so the role of the named hash is different from the classic “bulk data MAC” mental model.

Operationally, what matters is this:

  • prefer AEAD suites over older MAC-then-encrypt CBC suites
  • prefer SHA2-era constructions over SHA1-era leftovers
  • do not spend too much time tuning a legacy suite set when TLS 1.3 eliminates much of this complexity anyway

TLS 1.3 simplified the suite story

TLS 1.3 deliberately removed a lot of cipher suite complexity.

The main standardized suites you will actually care about are:

TLS_AES_256_GCM_SHA384
TLS_AES_128_GCM_SHA256
TLS_CHACHA20_POLY1305_SHA256

That is a massive improvement from an operational standpoint.

Key exchange and authentication are handled differently in TLS 1.3, so the suite name is no longer trying to encode every dimension of the handshake. You do not manage ECDHE-RSA-... vs ECDHE-ECDSA-... in the same way anymore.

I like this because it cuts down on false precision in configs. For TLS 1.3, you spend less time arguing over long cipher strings and more time making sure protocol support, certificate type, and curves are sane.

Weak ciphers you should remove

There are a few categories I remove without much debate unless I am trapped by a legacy dependency.

RC4

Deprecated, obsolete, and not something to keep around for public web traffic.

3DES

Deprecated in modern hardened configurations. It is slow, old, and vulnerable to issues like SWEET32 in practical contexts.

NULL ciphers

No encryption. These belong nowhere in a real HTTPS deployment.

EXPORT suites

Historical baggage from an era you do not want to revisit.

anonymous suites (aNULL)

No certificate-based authentication. That defeats the point of the public web PKI model.

MD5-era leftovers

If you still see MD5-associated suites in the stack, treat that as technical debt to remove.

A useful way to filter out obviously bad families locally is:

openssl ciphers -v 'ALL:!aNULL:!eNULL:!EXPORT:!DES:!3DES:!RC4:!MD5:!PSK:!SRP'

That is not a final production string, but it is a quick sanity check when you are comparing capabilities.

Recommended Nginx configuration

For modern public-facing Nginx deployments, I usually start from Mozilla’s recommendations and then adjust for the actual compatibility target. Mozilla’s SSL config generator remains one of the better references when you need a baseline that is maintained for current browser behavior.

A solid current Nginx starting point looks like this:

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
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_ecdh_curve X25519:secp384r1:prime256v1;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;

A few notes from production work:

  • ssl_prefer_server_ciphers off is fine for modern Nginx with TLS 1.3 in play; clients can choose appropriately, and modern browsers do a reasonable job.
  • If your environment has hardware acceleration that strongly favors AES-GCM, performance testing may justify prioritizing it differently than on mixed mobile traffic where ChaCha20 helps.
  • Do not copy giant cipher strings from old blog posts. Most are stale.

Performance and client mix matter more than people admit

Cipher hardening is not only about removing weak suites. It is also about understanding what your client base and CPU architecture do well. On x86 servers with AES-NI, AES-GCM is usually the obvious choice and performs very well. On some mobile devices and lower-power systems, ChaCha20-Poly1305 can be faster because it does not depend on dedicated AES acceleration in the same way.

That is why I do not like one-size-fits-all advice that says “always prefer suite X first” without looking at traffic patterns. If most of your users are on modern desktop browsers behind a CDN that already optimizes edge crypto, your priorities may differ from a direct-to-origin API serving lots of mobile clients in mixed hardware regions.

The practical lesson is simple: security comes first, but once the weak stuff is gone, benchmark instead of guessing. If you terminate TLS at origin, I like to watch CPU usage during peak traffic after major cipher changes. A config can be cryptographically fine and still be the wrong operational choice if it pushes unnecessary load onto already busy frontends.

Validate before reload:

sudo nginx -t
sudo systemctl reload nginx

If you are also tuning the rest of the TLS stack, this Nginx SSL configuration guide and this reverse proxy TLS guide are useful companion reads.

Recommended Apache configuration

On Apache, the intent is the same even though directives differ:

SSLProtocol             -all +TLSv1.2 +TLSv1.3
SSLCipherSuite          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
SSLHonorCipherOrder     off
SSLSessionTickets       off

Then validate:

sudo apachectl configtest
sudo systemctl reload apache2

On older Apache or OpenSSL combinations, you may need to confirm that ChaCha20 support actually exists before assuming the config will parse.

How I test cipher support from the command line

List supported local suites

To see what your OpenSSL build knows about:

openssl version
openssl ciphers -v

To inspect only stronger TLS 1.2 era suites:

openssl ciphers -v 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!3DES:!RC4:!MD5'

Force a specific cipher with s_client

This is useful when you want to prove a server still accepts something you intended to remove.

openssl s_client -connect example.com:443 -servername example.com \
  -tls1_2 -cipher 'ECDHE-RSA-AES128-GCM-SHA256'

If the handshake succeeds, the server accepted that suite. If it fails with no shared cipher, your restriction is working.

For a deeper workflow around s_client, see the OpenSSL s_client debugging guide.

Enumerate suites remotely with Nmap

One of the most useful checks:

nmap --script ssl-enum-ciphers -p 443 example.com

This gives you a practical external view of:

  • protocol versions offered
  • ciphers per protocol
  • server preference behavior
  • weak suites still enabled

I prefer testing from another host rather than the server itself when possible. Local inspection tells me what I configured. External scanning tells me what is actually reachable after proxies, load balancers, CDN settings, and mistaken reload assumptions.

Verifying forward secrecy

If you want to confirm that the endpoint negotiates forward-secret suites, there are a few angles.

First, inspect Nmap results for ECDHE or DHE suites on TLS 1.2 and modern TLS 1.3 support.

Second, test a specific ECDHE suite explicitly:

openssl s_client -connect example.com:443 -servername example.com \
  -tls1_2 -cipher 'ECDHE-RSA-AES256-GCM-SHA384'

Third, confirm that plain RSA key exchange is not available:

openssl s_client -connect example.com:443 -servername example.com \
  -tls1_2 -cipher 'AES256-SHA'

If that older handshake still succeeds and you do not need it for legacy clients, I would phase it out.

Common mistakes when hardening cipher suites

I have made some of these myself.

Copying a config that does not match the installed OpenSSL version

The syntax may validate differently across distributions and package versions. Always test on the target host.

Focusing on cipher strings while leaving old protocols enabled

If TLS 1.0 or 1.1 is still enabled, shaving one CBC suite from the string is not where your biggest risk lives.

Breaking compatibility without checking actual client requirements

I prefer aggressive hardening, but not blind hardening. If the service supports old industrial devices, Java runtimes, or embedded clients, validate the real compatibility matrix before you cut them off.

Assuming TLS 1.3 suites are controlled by the old TLS 1.2 cipher directive

Server behavior differs by software version. Read the current documentation for your Nginx, Apache, and OpenSSL combination.

Forgetting the CDN or load balancer layer

I have seen teams harden the origin server beautifully while the public-facing CDN still offered weaker settings. Always test the endpoint users actually hit.

SSLLabs grading criteria and what to care about

People chase SSLLabs grades too mechanically, but the report is still useful.

Things that materially matter:

  • old protocol versions disabled
  • weak suites removed
  • forward secrecy supported
  • secure certificate chain
  • sensible key sizes
  • session resumption behavior
  • stapling and related hygiene

Things that matter less than people think:

  • squeezing every last point from a grade when it compromises operational compatibility you actually need

I use SSLLabs as a diagnostic lens, not as theology.

What I use as a sane reference point

I still check the Mozilla SSL Configuration Generator whenever I am building a fresh server profile or revisiting an old one. Not because I copy it blindly, but because it is one of the few references that usually reflects current browser reality better than random forum snippets and decade-old blog posts.

The value is less about getting a magic cipher string and more about validating direction:

  • which protocol versions should still be enabled
  • whether the compatibility target is modern, intermediate, or old
  • whether your own config has drifted into unnecessary complexity

I have inherited too many servers where the cipher string had clearly been copy-pasted from several different eras. Mozilla’s generator is a good antidote to that kind of configuration archaeology.

Deprecated guidance vs current practice

Deprecated: long custom cipher strings from 2016 blog posts

Those strings are often full of compatibility cargo cults and historic leftovers.

Deprecated: prioritizing CBC because “it works everywhere”

It may work, but that is not the same as being the right current default.

Current practice: keep TLS 1.2 clean and let TLS 1.3 simplify things

For most public services, that means:

  • only TLS 1.2 and TLS 1.3
  • no RC4, 3DES, NULL, EXPORT, or anonymous suites
  • prefer ECDHE for TLS 1.2
  • prefer AEAD suites
  • verify externally after every change

Troubleshooting a bad cipher deployment

When a change goes wrong, my workflow is consistent.

1. Validate the config syntax

sudo nginx -t
sudo apachectl configtest

2. Confirm the process reloaded

sudo systemctl status nginx --no-pager
sudo systemctl status apache2 --no-pager

3. Test protocol versions explicitly

openssl s_client -connect example.com:443 -servername example.com -tls1_2
openssl s_client -connect example.com:443 -servername example.com -tls1_3

4. Scan externally

nmap --script ssl-enum-ciphers -p 443 example.com

5. Compare origin vs edge

If you sit behind a CDN or load balancer, test both the public hostname and the origin directly if policy allows. That gap explains more TLS surprises than people admit.

Conclusion

A cipher suite is not just a string to paste into a config. It describes the security properties your clients will actually rely on. In practical hardening work, the big wins are clear: keep only TLS 1.2 and 1.3, prefer forward-secret ECDHE in TLS 1.2, favor AEAD suites like AES-GCM and ChaCha20-Poly1305, and remove old baggage like RC4, 3DES, NULL, EXPORT, and anonymous suites.

TLS 1.3 made life easier, but you still need to verify the live endpoint with tools like openssl ciphers, openssl s_client, and nmap --script ssl-enum-ciphers. I do not trust a cipher policy until I have seen the negotiated result from outside the box. That habit has saved me more than once.

Scroll to Top