There are many wordy articles on configuring your web server’s TLS ciphers. This is not one of them. Instead, I will share a configuration that scores a straight “A” on Qualys’s SSL Server Test in 2023.

Disclaimer: I’m updating this post continually to represent what I consider the best practice at the moment – there are way too many dangerously outdated articles about TLS-deployment out there already.

Therefore, it may be a good idea to check back from time to time because the crypto landscape is changing pretty quickly at the moment. You can follow me on Mastodon and Twitter to get notified about noteworthy changes.

If you find any factual problems, please reach out to me and I will fix it ASAP.

If you want to skip directly to the config snippets, here’re the anchors:

Rationale

A lot has changed since I wrote this article in 2013. Unusually enough, things got better and simpler.

To run a secure web server in 2023, all you have to do is:

  1. Enable TLS 1.2 and TLS 1.3 only.
  2. Enable a few modern ciphers (mostly AES in GCM mode for devices with hardware acceleration and ChaCha20 for devices without1).
  3. Since all ciphers are secure enough, let the client pick.

That’s it on the web server side. Things got more complicated on the client side, but that is out of scope for this article.


Nowadays, it’s much more difficult to actually weaken your security on modern distributions, which is why there’s a section at the end on this topic.

Software and Versions

On the server side you should update your OpenSSL to 1.0.1c+ so you can support TLS 1.2, GCM, and ECDHE as soon as possible. Fortunately, that’s already the case since Ubuntu 12.04 LTS. For TLS 1.3, you need OpenSSL 1.1.1 which you can have as of Ubuntu 18.04 LTS. Both are end-of-life, so if you’re running a server that’s still receiving updates, you’re fine.

On the client side the browser vendors have caught up years ago. As of now, Chrome 30, Internet Explorer 11 on Windows 8, Safari 7 on OS X 10.9, and Firefox 26 all support TLS 1.2. All modern browsers also support TLS 1.3.

TLS 1.3

TLS 1.3 brings some nice improvements regarding performance and security, but complicates a few things and requires special configuration on most servers.

To be clear: if you can only support TLS 1.2 for the time being, that’s perfectly fine.

RC4

There used to be a bullet point suggesting using RC4 to avoid BEAST and Lucky Thirteen. And ironically that used to be the original reason for this article: when Lucky Thirteen came out the word in the streets was: “use RC4 to mitigate” and everyone was like “how!?”.

Unfortunately, shortly thereafter RC4 was found broken in a way that makes deploying TLS with it nowadays a risk. While BEAST et al. require an active attack on the browser of the victim, passive attacks on RC4 ciphertext are getting stronger every day. In other words: it’s possible that it will become feasible to decrypt intercepted RC4 traffic eventually and the NSA probably already is. Microsoft even issued a security advisory that recommends disabling RC4. As of 2015, there’s also an RFC.

The String

ECDH+AESGCM:ECDH+CHACHA20:ECDH+AES256:ECDH+AES128:!aNULL:!SHA1:!AESCCM

You can test it against your OpenSSL installation using

$ openssl ciphers -v 'ECDH+AESGCM:ECDH+CHACHA20:ECDH+AES256:ECDH+AES128:!aNULL:!SHA1:!AESCCM'

to see what’s supported.

Two quick notes:

  • The string asks for ECDH which technically is the static version of ECDHE without perfect forward secrecy. That’s really bad. The reason it’s in there is that

    1. No server ever supported them.
    2. Before OpenSSL 1.0.2, you couldn’t ask for ECDHE specifically2. You can try it with Docker in ubuntu:trusty: you’ll get an empty list3.

    I assume that most of you have an up-to-date OpenSSL, however leaving it as it is has no security implications and is more compatible/less likely to cause me confused questions in my mailbox.

  • Qualys will complain about two ciphers4 being weak, but they’re the price to keep Java 8, Internet Explorer 11, and older Safaris around.


The string allows AES-256 but does so mostly for liability reasons because customers may insist on it for bogus reasons.

However, quoth a cryptographer:

AES-128 isn’t really worse than AES-anythingelse, at least not in ways you care about

The very simplified gist here is that the only reason for having 256-bit keys are quantum computers which are less likely to become a problem than the key scheduling issues in AES-256. But let me stress that both are fine. It’s just that adding AES-256 ciphers doesn’t improve your security in practice.

So, if AES-128 is fine for you, feel free to add an ‘:!AES256’ to the end of the cipher string and many browsers will prefer AES-128.

Apache

SSLProtocol -all +TLSv1.2 +TLSv1.3

SSLHonorCipherOrder Off
SSLCipherSuite ECDH+AESGCM:ECDH+CHACHA20:ECDH+AES256:ECDH+AES128:!aNULL:!SHA1:!AESCCM
SSLCipherSuite TLSv1.3 TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256

nginx

nginx added support for TLS 1.2 in 1.0.12 (all supported LTS distributions) and TLS 1.3 in 1.13.0 (Ubuntu 18.04 LTS and later).

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDH+AESGCM:ECDH+CHACHA20:ECDH+AES256:ECDH+AES128:!aNULL:!SHA1:!AESCCM;

As of nginx 1.19.4, you can also configure TLS 1.3 ciphers:

ssl_conf_command Ciphersuites TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256;

HAProxy

The oldest currently supported HAProxy is version 2.0 which is fine for both TLS 1.2 and TLS 1.3.

global
  ssl-default-bind-options ssl-min-ver TLSv1.2 prefer-client-ciphers
  ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
  ssl-default-bind-ciphers ECDH+AESGCM:ECDH+CHACHA20:ECDH+AES256:ECDH+AES128:!aNULL:!SHA1:!AESCCM

  ssl-default-server-options ssl-min-ver TLSv1.2
  ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
  ssl-default-server-ciphers ECDH+AESGCM:ECDH+CHACHA20:ECDH+AES256:ECDH+AES128:!aNULL:!SHA1:!AESCCM

  tune.ssl.default-dh-param 2048

As you might have noticed by the cipher suite names, the ssl-default-XXX-ciphersuites options are for TLS 1.3 and ssl-default-XXX-ciphers are for TLS 1.2 (and older).

prefer-client-ciphers is always implied with OpenSSL 1.1.1 and the client preferring ChaCha20-Poly1305 (meaning it’s probably a phone with slow AES).

TLS 1.3’s Zero Round Trip Time Resumption

TLS 1.3 also introduced an optimization called Zero Round Trip Time Resumption (0-RTT). It makes TLS handshakes of returning clients faster, but has some inherent replay attack risks. I’m personally not allowing it facing the public Internet, but if the backends are TLS 1.3-ready, I use the force-tls13 allow-0rtt server options.

Bonus Points

  • If you have OpenSSL 1.1.0 or later, it’s also worth adding :@SECLEVEL=2 to your cipher string as a protection against weak keys (the other limits of the security levels are already handled by the cipher string).

  • In April 2014, Qualys have updated their requirements and the cipher suites here are still “A”–material. If you want an “A+” though, you’ll need to tweak your HTTP headers too. This goes way beyond the scope of this article, check out securityheaders.io to get started.

Legacy Clients With Modern Distributions

Unlike 2013, you have to put effort into making your server less secure. This can be necessary if the endpoint doesn’t serve browsers but (potentially ancient) IoT devices, routers, and other embedded systems. It also causes headaches to open source projects that need to maintain backward compatibility.

So if you need to expose TLS 1.0 on a modern Ubuntu 20.04 LTS, you have to either tinker with /etc/ssl/openssl.cnf or create a new one by creating a copy, do the following changes, and pass the file name of your new config to the server using the OPENSSL_CONF environment variable5.

In any case, you have to add openssl_conf = default_conf to the top of the config file and then append

[ default_conf ]

ssl_conf = ssl_sect

[ssl_sect]

system_default = ssl_default_sect

[ssl_default_sect]
MinProtocol = None

to the end6.

Once done, you can use my old cipher string that is still reasonably secure:

ECDH+AESGCM:ECDH+CHACHA20:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS:!AESCCM;

Make sure to restart the server that you are trying to affect.

Unfortunately, the server won’t be able to tell you whether it worked. For example, I’ve spent hours trying to figure out why ssl-min-ver TLSv1.0 was doing nothing on my HAProxy 2.2 while getting my logs flooded with SSL handshake failure.

You can test that everything is working as expected by forcing a TLS 1.0 client connection to your host:

$ openssl s_client -tls1 -connect your-host:443

If it worked, you’ll see connection information. If not, you’ll get a handshake error.

Finally

Make sure to test your server afterward! If you can’t use an online service, you can also use nmap:

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

A still common problem are weak DH parameters. Please refer to this guide on how to fix that, if you still have to use DHE. Sadly, except for HAProxy, it’s a bit more involved than just setting an option.

Also test your HTTP headers. A lot has changed here in the past 7 years and you won’t get an A+ on Qualys if you don’t put at least some effort into this.

If you want to learn more about deploying SSL/TLS, the excellent book Bulletproof SSL and TLS has just (November 2020) published a preview of their second edition and I can’t recommend it enough (this is not an affiliate link).

For investigating the SSL/TLS behavior of your browser, Qualys also has a page for that.

History

I’ve been keeping this guide up to date since 2013 throughout the very tumultuous evolution of modern TLS (as the following history shows):

  • 2021-02-11: Added TLS 1.3 configuration details for nginx 1.19.4 and later.
  • 2020-11-21: Rewrote and updated for TLS 1.3.
  • ‌2020-03-23: Added that you have to use the best cipher on the page to get an A. I will do more updates when I find the time.
  • ‌2019-06-23: Disabled AESCCM which is safe but uncommon and slow.
  • 2018-10-04: Small fix to the HAProxy next steps config: prefer-client-ciphers is neither needed nor allowed in ssl-default-server-options.
  • 2018-08-09: Added next steps to drop RSA, and going ECDHE-only. Added ChaCha20 to the default string for the case a client supports ChaCha20 but no AES-GCM (I’m not aware of any though).
  • 2017-06-12: Added DHE param size to HAProxy.
  • 2016-08-24: Removed 3DES because of SWEET32. This drops IE 8 on Windows XP support which shouldn’t be a concern in 2016.
  • 2015-05-20: The new weakdh/Logjam attack doesn’t affect you if you followed these instructions. It might be worthwhile though to create your own DH groups with at least 2048 bits as described in this guide.
  • 2015-01-16: Added a note on ECDSA because there seemed to be some confusion about it.
  • 2014-11-25: Added HAProxy, courtesy of Sander Klein.
  • 2014-10-24: Updated the TLS compression part about Red Hat/CentOS. TL;DR: it’s secure by default now.
  • 2014-10-21: Clarified that Internet Explorer 8 on Windows XP works fine with TLSv1-only. The original wrong claim stemmed from the fact that I double-checked using SauceLabs and it turned out that they don’t use Windows XP when you ask for it but Windows Server 2003 R2 instead. Since then, I re-tested using an actual Windows XP workstations running Internet Explorer 8 and it works fine.
  • 2014-10-15: Disabled SSLv3 because of POODLE.
  • 2014-04-11: Added note on CentOS/RHEL 6 and nginx + ECDHE.
  • 2014-01-17: RSA+AES has been split into RSA+AESGCM:RSA+AES. This is a very minor update that only matters if you have TLS 1.2 but neither ECDHE nor DHE (which is rather rare). It makes sure that RSA-AES-128-GCM is preferred over RSA-AES-256-CBC in these cases. Since both are just fallbacks and you should use PFS ciphers, this is just minutiae.

Credits: The initial version and the big November 2020 update of this article has been kindly proof-read by Christian Heimes. The old cipher string was based on one by Zooko Wilcox-O’Hearn who – unlike me – is an actual cryptographer. The errors are all still mine.


  1. Nowadays mostly phones. ↩︎

  2. Instead, you had to set a flag additionally to the cipher string. ↩︎

  3. docker run --rm -it ubuntu:trusty openssl ciphers ECDHE

    vs

    docker run --rm -it ubuntu:trusty openssl ciphers ECDH ↩︎

  4. TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384 and TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256 ↩︎

  5. This can be done using systemd unit files but goes beyond the scope of this article. ↩︎

  6. Shamelessly stolen and adapted from the linked Python bug. The workaround is from Vladyslav Bondar and Tal Einat. ↩︎