Fixing Nginx 'SSL: no shared cipher' Error When Using proxy_pass to HTTPS Backend
Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 10–20 mins
TL;DR
- What broke: Nginx cannot complete a TLS handshake with the upstream backend because no cipher suite is mutually supported — all proxied requests fail with
502 Bad Gateway. - How to fix it: Explicitly set
proxy_ssl_protocolsandproxy_ssl_ciphersin your Nginx location block to match what the backend actually accepts; verify withopenssl s_client. - Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your Nginx config and get a corrected diff without sending your config to any third-party server.
The Incident (What Does the Error Mean?)
Your Nginx error log shows:
2024/01/15 03:42:11 [error] 1234#1234: *1 SSL_do_handshake() failed
(SSL: error:1408A0C1:SSL routines:ssl3_get_client_hello:no shared cipher)
while SSL handshaking to upstream, client: 10.0.0.5,
server: api.internal, request: "POST /v1/data HTTP/1.1",
upstream: "https://10.0.1.22:8443/v1/data"
Immediate consequence: Every request hitting this location block returns 502 Bad Gateway. The upstream backend is alive and listening — Nginx just cannot shake hands with it. This is a complete service outage for that proxy path, not a partial degradation.
The handshake dies at ClientHello/ServerHello negotiation. The backend advertises cipher suites; Nginx sends its own list; the intersection is empty. OpenSSL aborts.
The Attack Vector / Blast Radius
This is a misconfiguration-induced outage, not an active exploit — but the blast radius is severe and the security implications are non-trivial:
Outage vector: If your Nginx is the sole ingress proxy to this backend (typical in microservice meshes), 100% of traffic to that upstream is dead. Load balancers will mark the Nginx node healthy (it's responding) while every upstream call fails silently from the client's perspective.
The dangerous workaround trap: Engineers under pressure will add proxy_ssl_verify off and throw proxy_ssl_ciphers ALL or proxy_ssl_ciphers DEFAULT to "just make it work." This disables certificate validation and opens the proxy path to MITM attacks on your internal east-west traffic. An attacker with network access between Nginx and the backend can intercept decrypted payloads — database credentials, session tokens, internal API keys.
Why this happens in production:
- Backend was upgraded to a hardened TLS config (e.g., TLS 1.3 only, FIPS-restricted ciphers) without updating Nginx proxy directives.
- Nginx was compiled against an older OpenSSL (< 1.1.1) that lacks TLS 1.3 support.
- Backend uses an ECC certificate requiring ECDHE cipher suites, but Nginx
proxy_ssl_ciphersdefaults or was explicitly set to RSA-only suites. - mTLS was enabled on the backend but Nginx has no
proxy_ssl_certificateconfigured.
How to Fix It
Step 0: Diagnose the backend's actual cipher list
Run this before touching Nginx config. You need ground truth.
# Check what the backend actually supports
openssl s_client \
-connect 10.0.1.22:8443 \
-tls1_2 \
-cipher 'ECDHE-RSA-AES256-GCM-SHA384' \
-servername api.internal \
</dev/null 2>&1 | grep -E 'Cipher|Protocol|Verify'
# Enumerate all accepted ciphers (requires nmap)
nmap --script ssl-enum-ciphers -p 8443 10.0.1.22
If openssl s_client succeeds with a specific cipher, that's your target.
Basic Fix — Align proxy_ssl directives to backend reality
location /api/ {
proxy_pass https://10.0.1.22:8443/;
- # Missing or wrong TLS directives — handshake fails
- proxy_ssl_protocols TLSv1;
- proxy_ssl_ciphers HIGH:!aNULL:!MD5;
+ proxy_ssl_protocols TLSv1.2 TLSv1.3;
+ proxy_ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
+ proxy_ssl_prefer_server_ciphers off;
+ proxy_ssl_server_name on;
+ proxy_ssl_verify on;
+ proxy_ssl_trusted_certificate /etc/nginx/certs/internal-ca.crt;
+ proxy_ssl_verify_depth 3;
}
Reload, don't restart:
nginx -t && systemctl reload nginx
Enterprise Best Practice — Full mTLS proxy with session reuse
If your backend requires mutual TLS (common in service meshes, Vault, internal PKI):
upstream backend_pool {
server 10.0.1.22:8443;
server 10.0.1.23:8443;
+ keepalive 32;
}
location /api/ {
proxy_pass https://backend_pool;
- proxy_ssl_verify off; # NEVER in production
+ # mTLS: Nginx presents its own cert to the backend
+ proxy_ssl_certificate /etc/nginx/certs/nginx-client.crt;
+ proxy_ssl_certificate_key /etc/nginx/certs/nginx-client.key;
+
+ # Backend verification
+ proxy_ssl_trusted_certificate /etc/nginx/certs/internal-root-ca.crt;
+ proxy_ssl_verify on;
+ proxy_ssl_verify_depth 3;
+
+ # Protocol and cipher hardening — match your backend's openssl output
+ proxy_ssl_protocols TLSv1.2 TLSv1.3;
+ proxy_ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:TLS_AES_256_GCM_SHA384;
+ proxy_ssl_prefer_server_ciphers off;
+ proxy_ssl_server_name on;
+ proxy_ssl_name api.internal;
+
+ # Session reuse across keepalive connections (reduces handshake overhead)
+ proxy_ssl_session_reuse on;
+
proxy_http_version 1.1;
proxy_set_header Connection "";
}
If Nginx is compiled against OpenSSL < 1.1.1, TLS 1.3 is unavailable regardless of config. Check:
nginx -V 2>&1 | grep -o 'OpenSSL [0-9.]*'
# Must be OpenSSL 1.1.1+ for TLS 1.3
On older systems, pin to TLSv1.2 only and ensure ECDHE suites are available.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
1. Pre-deploy TLS handshake smoke test
Add this to your deployment pipeline before Nginx reload:
#!/bin/bash
# ci/tls-handshake-check.sh
UPSTREAM_HOST="10.0.1.22"
UPSTREAM_PORT="8443"
SNI_NAME="api.internal"
result=$(openssl s_client \
-connect "${UPSTREAM_HOST}:${UPSTREAM_PORT}" \
-servername "${SNI_NAME}" \
-CAfile /etc/nginx/certs/internal-ca.crt \
</dev/null 2>&1)
if echo "$result" | grep -q "no shared cipher"; then
echo "FATAL: TLS cipher mismatch detected. Blocking deploy."
exit 1
fi
echo "TLS handshake OK: $(echo "$result" | grep 'Cipher is')"
2. Checkov / OPA policy — block proxy_ssl_verify off
# checkov custom check: CKV_NGINX_TLS_VERIFY
# Fail any Nginx config containing proxy_ssl_verify off
import re
from checkov.common.models.enums import CheckResult
def check_nginx_ssl_verify(config_content: str) -> CheckResult:
if re.search(r'proxy_ssl_verify\s+off', config_content):
return CheckResult.FAILED
return CheckResult.PASSED
3. Terraform / Ansible — pin cipher suites as variables, not hardcoded strings
# variables.tf
variable "nginx_proxy_ssl_ciphers" {
description = "Approved cipher suites for upstream proxy TLS"
type = string
default = "ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256"
validation {
condition = !can(regex("NULL|EXPORT|DES|RC4|MD5|aNULL", var.nginx_proxy_ssl_ciphers))
error_message = "Weak or null ciphers detected in proxy_ssl_ciphers."
}
}
4. Automated cert/cipher drift detection
Schedule a weekly nmap --script ssl-enum-ciphers against all internal upstreams and diff against your approved cipher baseline. Alert on any new cipher appearing or any approved cipher disappearing — the latter is your early warning for this exact failure mode before it hits production.