In nginx, what do the `ssl_protocols` and `ssl_ciphers` directives control, and why does listing a TLS 1.3 suite such as TLS_AES_128_GCM_SHA256 in `ssl_ciphers` have no effect?
answer
- one directive picks versions, one filters suites
- TLS 1.3 suite names carry no key exchange
- OpenSSL keeps two separate lists
- ssl_conf_command Ciphersuites is the 1.3 knob
- log $ssl_protocol before dropping a version
basics
~20 sssl_protocols selects which TLS versions nginx will negotiate. ssl_ciphers is an OpenSSL cipher string that applies only to TLS 1.2 and below; TLS 1.3 suites live in a separate OpenSSL list, changed with ssl_conf_command Ciphersuites.
solid answer
~40 s`ssl_protocols` is the version allowlist — a typical modern line is `ssl_protocols TLSv1.2 TLSv1.3;`, which makes nginx refuse a handshake from anything older with a protocol alert. `ssl_ciphers` takes an OpenSSL cipher string that filters the suites offered for **TLS 1.2 and earlier**. TLS 1.3 redefined suites entirely: they carry no key-exchange or authentication component, and OpenSSL keeps them in a separate list that `ssl_ciphers` does not touch. So adding `TLS_AES_128_GCM_SHA256` there changes nothing — and the three TLS 1.3 suites OpenSSL enables by default are already all strong. If you genuinely must change them, nginx 1.19.4 and later expose `ssl_conf_command Ciphersuites ...` against OpenSSL 1.1.1+. Likewise `ssl_prefer_server_ciphers on;` only orders the pre-1.3 list; modern published profiles turn it off because the TLS 1.3 choices no longer need policing.
code
nginx · 8 lineshttp {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
log_format tlsinfo '$remote_addr $ssl_protocol $ssl_cipher $request';
access_log /var/log/nginx/access.log tlsinfo;
}go deeper
Recall that ssl_protocols lists the TLS versions you accept and ssl_ciphers is an OpenSSL cipher string, and that the modern baseline is TLS 1.2 plus TLS 1.3.
Explain why TLS 1.3 suite names look different — no key exchange or authentication in the name — and that OpenSSL keeps them in a separate list reached through ssl_conf_command Ciphersuites, not ssl_ciphers.
Demonstrate that you measure before hardening: log $ssl_protocol and $ssl_cipher, size the affected client population, and verify the result with openssl s_client against specific versions rather than trusting the config file.
Own the deprecation as a programme: one shared TLS policy across every edge, evidence-based dates for dropping a version, an announced migration path for the clients you break, and a plan for the fleet that turns out to be un-upgradable.
## Two directives, two different jobs `ssl_protocols` enumerates the protocol versions nginx will agree to. Anything not listed is refused during the handshake: the client gets a TLS alert and the connection dies, which surfaces to users as a connection error rather than an HTTP status. That bluntness is the point — dropping TLS 1.0/1.1 is a deliberate decision to break old clients. `ssl_ciphers` takes an OpenSSL cipher **string**, the mini-language of `HIGH:!aNULL:!MD5` and `ECDHE-ECDSA-AES128-GCM-SHA256:...`. It filters and orders the suites nginx offers, and it is interpreted by OpenSSL, not by nginx. A common production block looks like: ```nginx ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ``` ## Why TLS 1.3 does not listen to ssl_ciphers A TLS 1.2 suite name bundles four decisions: key exchange, authentication, bulk cipher and MAC — `ECDHE-RSA-AES128-GCM-SHA256` states all four at once. TLS 1.3 removed the first two from the suite: key exchange is always an ephemeral Diffie-Hellman group negotiated separately, and authentication comes from the certificate together with the signature-algorithms extension. What remains is the AEAD and hash pair, which is why the names look different — `TLS_AES_128_GCM_SHA256`, `TLS_AES_256_GCM_SHA384`, `TLS_CHACHA20_POLY1305_SHA256`. Because the shape changed, OpenSSL 1.1.1 kept TLS 1.3 suites in an entirely separate list with its own API rather than shoehorning them into the legacy cipher string. `ssl_ciphers` maps onto the legacy setting, so TLS 1.3 names placed there are simply not consulted. Nothing errors; the directive quietly does not apply — which is why this survives as a misconception rather than as a broken config. nginx 1.19.4 added a passthrough for OpenSSL configuration commands: ```nginx ssl_conf_command Ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256; ``` This is rarely needed. The three defaults are all AEAD constructions with no known weakness, and narrowing them mostly buys interoperability risk. Treat it as an escape hatch for a compliance requirement, not as routine hardening. ## ssl_prefer_server_ciphers, and why modern profiles turn it off `ssl_prefer_server_ciphers on;` tells OpenSSL to pick the first suite in the **server's** order that the client also supports, instead of honouring the client's preference. It was standard advice when client lists were full of weak options and a server had to steer them. Two things changed: client preferences became well-curated (a phone that prefers ChaCha20-Poly1305 does so because it has no AES hardware acceleration, and overriding that makes it slower), and TLS 1.3 removed the weak options entirely. Published modern profiles therefore leave it off and let the client choose. ## Where these directives take effect — the SNI caveat Certificates are selected per virtual server via SNI, but `ssl_protocols` and `ssl_ciphers` are applied when the SSL context for the listening socket is created — before the SNI callback has told nginx which `server` block the client asked for. In practice, setting different values in different name-based virtual servers on the same `listen` address does not give you different policies per hostname; the default server's settings govern. Put protocol and cipher policy at `http` level, and if one hostname genuinely needs a different policy, give it its own address or port. ## Verifying instead of assuming ```bash openssl s_client -connect example.com:443 -servername example.com -tls1_1 </dev/null openssl s_client -connect example.com:443 -servername example.com -tls1_3 </dev/null | grep -i cipher ``` The first should fail if TLS 1.1 is excluded; the second shows the negotiated TLS 1.3 suite. Testing beats reading config, because a distribution's `nginx.conf` often sets `ssl_protocols` at `http` level and an included snippet you did not write may override it — or fail to, thanks to the SNI caveat above. ## The judgement, not just the syntax Every version you drop breaks somebody. Before removing TLS 1.0/1.1 or narrowing suites, measure: nginx's log format can include `$ssl_protocol` and `$ssl_cipher`, so you can count real traffic by version for a week before deciding. Hardening blind is how a config change becomes an outage for one embedded client fleet nobody remembered.
- How would you decide whether it is safe to remove TLS 1.2 support entirely?Measure first: add `$ssl_protocol` to the access log format and count real traffic by version over a representative period, broken down by client type. TLS 1.2 still carries meaningful traffic from older Android, embedded devices and server-to-server clients you do not control. Decide on the measured share and on who owns those clients, then announce a date rather than flipping the directive.
- Why do modern published TLS profiles set ssl_prefer_server_ciphers off?Client preference now reflects real hardware: a device without AES acceleration puts ChaCha20-Poly1305 first because it is genuinely faster there. Overriding that order makes those clients slower for no security gain, since the remaining suites are all strong. The directive also does nothing for the TLS 1.3 suite list.
- Can two name-based virtual servers on the same listen address have different ssl_protocols values?Not reliably. The SSL context for the listening socket is set up before the SNI callback identifies the requested server, so protocol and cipher settings come from the default server for that socket even though the certificate is selected per name. If one hostname truly needs a different policy, give it a separate address or port.
saying these in an interview costs you the question
- Adding TLS_AES_256_GCM_SHA384 to ssl_ciphers hardens TLS 1.3
- ssl_prefer_server_ciphers still orders TLS 1.3 suites
- A longer cipher string is a stronger configuration
- ssl_protocols downgrades old clients rather than refusing them
- Each virtual server can reliably set its own ssl_protocols