How-to guide · Harden and secure

How to configure TLS securely (versions, ciphers, HSTS)

In one sentence

You will have a web server that offers only TLS 1.2 and 1.3 with forward-secret ciphers, staples OCSP only where it still applies, sends HSTS safely, and passes testing.

Task
Harden and secure
Platforms
  • nginx
  • Apache
  • Linux
  • Windows
Who it is for
Level
Practitioner · 6 steps
On this page
  1. Before you start
  2. Steps
  3. 1. Choose the intermediate profile
  4. 2. Check which OpenSSL your server uses
  5. 3. Set protocols, ciphers and groups
  6. 4. Decide about OCSP stapling
  7. 5. Add HSTS, carefully
  8. 6. Test the configuration, then reload
  9. Check it worked
  10. If something goes wrong
  11. Why this works
  12. Go deeper

Before you start

  • Administrator access to the server or load balancer that terminates TLS. If a load balancer or CDN terminates TLS, configure it there.
  • A working certificate served with its full chain (see How to install a certificate in nginx).
  • The versions of your server software and of the OpenSSL library it uses.
  • openssl for testing, and optionally testssl.sh, a free command-line scanner.

Why this matters: old defaults still allow protocol versions and ciphers that clients no longer need. Every one you remove is one less weakness to defend, and testing before you reload avoids surprise outages for older clients.

Steps

1. Choose the intermediate profile

Mozilla’s Server Side TLS guidelines have long been the standard reference for server configuration. They are now maintained by the TLSRef community working group, and this guide follows the intermediate profile of version 6.0 (April 2026), “the recommended configuration for the vast majority of services”:

Setting Intermediate profile
Protocols TLS 1.2 and TLS 1.3
TLS 1.3 cipher suites TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256 (the defaults)
TLS 1.2 ciphers ECDHE key exchange with AES-GCM or ChaCha20-Poly1305 only
Key exchange groups X25519MLKEM768 (OpenSSL 3.5 or later), X25519, prime256v1, secp384r1
Certificate ECDSA P-256 (recommended) or RSA 2048
Cipher order The client chooses
HSTS max-age=63072000 (two years)

The “modern” profile drops TLS 1.2 and suits services whose clients all support TLS 1.3, such as APIs used only by your own up-to-date software. Version 6.0 removed the “old” profile entirely. TLS 1.0 and 1.1 are gone from both, and the IETF agrees: RFC 8996 formally deprecated TLS 1.0 and 1.1 (March 2021).

For your exact server and versions, use the TLSRef Configurator, the continuation of the Mozilla SSL Configuration Generator. The old address, ssl-config.mozilla.org, now forwards there.

2. Check which OpenSSL your server uses

The library decides what is possible. Post-quantum hybrid key exchange needs OpenSSL 3.5 or later:

nginx -V 2>&1 | grep -i openssl
openssl version
built with OpenSSL 3.0.2 15 Mar 2022
OpenSSL 3.0.2 15 Mar 2022

This is our lab server on Ubuntu 22.04: nginx 1.18 with OpenSSL 3.0, so it must leave X25519MLKEM768 out of the group list.

3. Set protocols, ciphers and groups

nginx

Put the shared settings in the http block, so every server block inherits them:

http {
    # intermediate configuration
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1:secp384r1;   # OpenSSL 3.5+; else drop the first entry
    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_prefer_server_ciphers off;

    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;   # about 40000 sessions

    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        http2 on;                           # nginx 1.25.1+; older: "listen 443 ssl http2;"
        server_name www.example.com;
        ssl_certificate     /etc/ssl/www.example.com/fullchain.pem;
        ssl_certificate_key /etc/ssl/www.example.com/privkey.pem;
    }
}
  • ssl_ciphers applies to TLS 1.2 only. All TLS 1.3 suites are strong, and OpenSSL’s defaults match the profile.
  • Every TLS 1.2 cipher in the list uses ECDHE, so every connection has forward secrecy, and an AEAD cipher.
  • ssl_prefer_server_ciphers off lets the client pick: a phone without AES hardware can choose ChaCha20.
  • The list holds ciphers for both ECDSA and RSA certificates, so it works with either.

Apache httpd

The same profile for Apache 2.4 with mod_ssl, outside your VirtualHost blocks:

# intermediate configuration
SSLProtocol             -all +TLSv1.2 +TLSv1.3
SSLOpenSSLConfCmd       Curves X25519MLKEM768:X25519:prime256v1:secp384r1
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

Inside each <VirtualHost *:443>, point SSLCertificateFile at the certificate plus intermediates and SSLCertificateKeyFile at the key. As with nginx, drop X25519MLKEM768 when Apache’s OpenSSL is older than 3.5.

Other servers

The configurator covers HAProxy, Caddy, Traefik, Tomcat, Postfix and many more. On Windows, IIS uses the operating system’s Schannel library: protocols and cipher suites are set for the whole machine through Group Policy or the registry, as described in Microsoft’s TLS registry settings.

4. Decide about OCSP stapling

OCSP stapling lets the server send its certificate’s revocation status in the handshake. It only works when the certificate names an OCSP responder, and that is no longer a given: OCSP became optional and CRLs became mandatory for publicly trusted TLS certificates (15 March 2024), and Let's Encrypt has shut down its OCSP service (since 6 August 2025). Check your own certificate:

openssl x509 -in fullchain.pem -noout -ocsp_uri

Run against two live sites in October 2026, the command printed http://ocsp.digicert.com for www.digicert.com and nothing at all for letsencrypt.org.

  • An OCSP URL is printed: turn stapling on. In nginx, add ssl_stapling on;, ssl_stapling_verify on;, ssl_trusted_certificate pointing at the intermediate and root, and a resolver nginx can use to reach the responder. In Apache, SSLUseStapling On and an SSLStaplingCache.
  • Nothing is printed: leave stapling off. There is nothing to staple, and nginx logs a warning and ignores the setting.

5. Add HSTS, carefully

HTTP Strict Transport Security tells browsers to use only HTTPS for your site, for max-age seconds. Browsers accept the header only over HTTPS (RFC 6797 section 8.1), so redirect plain HTTP first:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    return 308 https://$host$request_uri;
}

Then raise max-age in stages, so a mistake costs minutes rather than years: start with max-age=300 (five minutes) while you test, then a week, then a month, then the profile’s two years. The final line in the HTTPS server block:

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

In Apache: Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains".

  • includeSubDomains covers every subdomain, including internal ones that browsers reach. Add it only when every one of them serves valid HTTPS.
  • preload asks browsers to build your domain into their HSTS list. The preload list requires includeSubDomains, preload and a max-age of at least 31536000 seconds (one year), and warns that “inclusion in the preload list cannot easily be undone”. Removal takes months to reach users. Never add preload as a default in a shared template.

6. Test the configuration, then reload

sudo nginx -t && sudo systemctl reload nginx
sudo apachectl configtest && sudo systemctl reload apache2

A reload keeps existing connections; a failed test leaves the running configuration untouched.

Check it worked

These are real results from our lab nginx with the configuration above (OpenSSL 3.0 as the client). TLS 1.3 and TLS 1.2 succeed, and TLS 1.2 uses ECDHE:

openssl s_client -connect 127.0.0.1:8443 -servername localhost -CAfile ca.crt -verify_hostname localhost -tls1_2 -brief </dev/null
CONNECTION ESTABLISHED
Protocol version: TLSv1.2
Ciphersuite: ECDHE-ECDSA-AES256-GCM-SHA384
Peer certificate: CN = localhost
Hash used: SHA256
Signature type: ECDSA
Verification: OK
Verified peername: localhost
Supported Elliptic Curve Point Formats: uncompressed:ansiX962_compressed_prime:ansiX962_compressed_char2
Server Temp Key: X25519, 253 bits

To prove the server refuses TLS 1.1, lower the client’s own security level first. Without it, OpenSSL 3 refuses locally with no protocols available, which tests nothing:

openssl s_client -connect 127.0.0.1:8443 -servername localhost -tls1_1 -cipher "DEFAULT:@SECLEVEL=0" </dev/null
openssl s_client -connect 127.0.0.1:8443 -servername localhost -tls1_2 -cipher AES128-SHA </dev/null
…:error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version:…:SSL alert number 70
…:error:0A000410:SSL routines:ssl3_read_bytes:sslv3 alert handshake failure:…:SSL alert number 40

Alert 70 (protocol_version) and alert 40 (handshake_failure) are the server saying no. Then check the header with curl -sI https://www.example.com/ | grep -i strict.

testssl.sh 3.2.4 checks everything at once. The protocol and header sections for the same lab server:

./testssl.sh -p -h https://localhost:8443
 SSLv2      not offered (OK)
 SSLv3      not offered (OK)
 TLS 1      not offered
 TLS 1.1    not offered
 TLS 1.2    offered (OK)
 TLS 1.3    offered (OK): final
...
 Strict Transport Security    730 days=63072000 s, includeSubDomains

If something goes wrong

  • nginx refuses to start with SSL_CTX_set1_curves_list("X25519MLKEM768:X25519:prime256v1:secp384r1") failed. Its OpenSSL is older than 3.5. Remove X25519MLKEM768 from the list (real message from our lab).
  • Old clients fail with ERR_SSL_VERSION_OR_CIPHER_MISMATCH or Java’s handshake_failure. Upgrade the client first. Re-enable an old cipher only for a named, dated exception.
  • curl: (35) with wrong version number usually means plain HTTP answered on the TLS port (curl: (35)).
  • A subdomain became unreachable after HSTS. It has no valid HTTPS, and browsers will insist on it until max-age runs out. Give it HTTPS; shortening the header only helps browsers that visit again.
  • The settings seem to have no effect. Another server block, an included file or a load balancer in front terminates TLS. Find the real endpoint with openssl s_client and the certificate it returns.

Paste any handshake error into the error finder to get its page:

Try it: Paste your errorOpen the full tool

Runs in your browser. Nothing you paste is sent anywhere.

The whole message or just the important line. A stack trace is fine; we look for the known parts.

Why this works

Each TLS connection starts with a handshake in which the client lists what it supports and the server picks the best common protocol, cipher and key-exchange group (the TLS 1.3 handshake). Limiting the server’s lists removes weak options without hurting modern clients. ECDHE gives forward secrecy, and hybrid groups add protection against “harvest now, decrypt later” (post-quantum cryptography). Stapling and its decline are explained in revocation.

Go deeper

Early edition. Checked against the standards; an independent expert review is in progress. Found a mistake? Tell us.

Last reviewed Edition 0.9