CVE-2025-23419: mTLS Session Resumption Bypass in NGINX
Problem
CVE-2025-23419 is a mutual TLS authentication bypass in NGINX. When multiple virtual hosts share a TLS session ticket key — which is the default behaviour — a client that successfully completes mTLS verification on one virtual host can resume that session on a different virtual host that requires mTLS, bypassing the certificate check on the second host.
The vulnerability is subtle: TLS session resumption is a performance optimisation that allows a client to reuse previously negotiated session parameters without repeating the full handshake. Session state is encoded in a ticket encrypted with a server-side key. When NGINX serves multiple server {} blocks, they share the same session ticket key by default. A session resumed on any virtual host is considered valid for any virtual host that shares the key.
The security consequence is that if a client has a valid certificate for virtual host A but not for virtual host B, and both are served by the same NGINX instance with shared session ticket keys, the client can:
- Complete a full mTLS handshake against virtual host A (which accepts the client’s certificate)
- Receive a session ticket encrypted with the shared key
- Present that session ticket to virtual host B (which requires a different certificate or a certificate the client does not have)
- NGINX resumes the session without re-verifying the client certificate, because the session ticket is valid
- The client accesses virtual host B without presenting an acceptable certificate
This affects any NGINX deployment that:
- Serves multiple virtual hosts from the same process
- Uses
ssl_verify_client on(mTLS enforcement) on at least one virtual host - Has not explicitly isolated session ticket keys per virtual host
Why this is not immediately obvious. Session resumption is designed to be transparent. The same connection security that was established in the original handshake is “resumed.” The design assumption is that the same client identity is being used — but NGINX’s per-virtual-host certificate requirements are not checked during resumption. The session ticket proves “this client previously completed a TLS handshake” but not “this client’s certificate is acceptable for this specific virtual host.”
Affected versions. NGINX before 1.27.4 (mainline) and 1.26.3 (stable). The fix introduces per-virtual-host session ticket key isolation.
Target systems: any NGINX deployment using mTLS (ssl_verify_client on) across multiple virtual hosts on the same server; API gateways using client certificate authentication; service mesh deployments with NGINX-based mTLS enforcement.
Threat Model
Adversary 1 — Cross-virtual-host certificate bypass. An attacker has a valid client certificate accepted by one NGINX virtual host (e.g., a low-privilege API endpoint) but not by a second, higher-privilege virtual host. They complete mTLS on the first host, capture the session ticket, then present it to the second host to bypass certificate verification.
Adversary 2 — Certificate authority downgrade. A multi-tenant NGINX deployment requires certificates from different CAs for different tenants. Tenant A’s certificate is accepted by tenant-a.example.com. Using session resumption, tenant A accesses tenant-b.example.com without a valid certificate from tenant B’s CA.
Adversary 3 — Revoked certificate persistence via session. A client certificate is revoked. NGINX is configured with OCSP or CRL checking. On the next connection attempt, the full handshake would fail. But if the client has a valid session ticket from before revocation, session resumption skips the certificate check — and therefore skips OCSP/CRL checking — allowing the revoked certificate to continue granting access until the session expires.
Configuration / Implementation
Step 1 — Determine if you are affected
# Check NGINX version
nginx -v
# Affected: < 1.27.4 (mainline), < 1.26.3 (stable)
# Check if multiple virtual hosts use ssl_verify_client
grep -r "ssl_verify_client" /etc/nginx/ 2>/dev/null
# Count virtual hosts with TLS on the same IP/port
grep -r "listen.*443\|listen.*ssl" /etc/nginx/ 2>/dev/null | sort -u
# Check if ssl_session_ticket_key is explicitly configured per server block
grep -r "ssl_session_ticket_key" /etc/nginx/ 2>/dev/null
# If this returns no results or only one global key, you are potentially affected
If ssl_verify_client on appears in more than one server block on the same port, and you are running a vulnerable version, investigate the exposure.
Step 2 — Apply the patch (preferred mitigation)
# Ubuntu/Debian
apt-get update && apt-get install -y nginx
# Verify version
nginx -v
# Should show: nginx version: nginx/1.26.3 or 1.27.4+
# RHEL/CentOS/Amazon Linux — via nginx.org repo
yum update nginx
# Verify the update is applied
nginx -v
Step 3 — Isolate session ticket keys per virtual host (workaround for unpatched)
If you cannot patch immediately, configure per-virtual-host session ticket keys to eliminate shared key state:
# Generate unique session ticket keys per virtual host
# Each key must be 80 bytes (for AES-256 + HMAC-SHA256)
openssl rand 80 > /etc/nginx/ssl/session-ticket-vhost-a.key
openssl rand 80 > /etc/nginx/ssl/session-ticket-vhost-b.key
openssl rand 80 > /etc/nginx/ssl/session-ticket-vhost-c.key
chmod 600 /etc/nginx/ssl/session-ticket-*.key
chown root:root /etc/nginx/ssl/session-ticket-*.key
# /etc/nginx/conf.d/vhost-a.conf
server {
listen 443 ssl;
server_name api-internal.example.com;
ssl_certificate /etc/ssl/certs/nginx.crt;
ssl_certificate_key /etc/ssl/private/nginx.key;
# mTLS enforcement
ssl_verify_client on;
ssl_client_certificate /etc/ssl/certs/internal-ca.crt;
# Isolate session ticket key to this virtual host only
# This prevents session resumption from being used to bypass cert verification
# on other virtual hosts
ssl_session_ticket_key /etc/nginx/ssl/session-ticket-vhost-a.key;
# Explicitly set session ticket timeout
ssl_session_timeout 1h;
location / {
proxy_pass http://internal-api;
# Pass verified client cert to upstream
proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Verify $ssl_client_verify;
}
}
# /etc/nginx/conf.d/vhost-b.conf
server {
listen 443 ssl;
server_name api-partner.example.com;
ssl_certificate /etc/ssl/certs/nginx.crt;
ssl_certificate_key /etc/ssl/private/nginx.key;
ssl_verify_client on;
ssl_client_certificate /etc/ssl/certs/partner-ca.crt;
# Different key from vhost-a — sessions cannot be shared between hosts
ssl_session_ticket_key /etc/nginx/ssl/session-ticket-vhost-b.key;
ssl_session_timeout 1h;
location / {
proxy_pass http://partner-api;
proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
proxy_set_header X-Client-DN $ssl_client_s_dn;
}
}
Step 4 — Alternatively, disable session tickets entirely
For the most conservative mitigation, disable TLS session tickets entirely. This eliminates the attack surface at the cost of session resumption performance:
# /etc/nginx/nginx.conf — global context
http {
# ...
# Disable session tickets entirely
# Session cache (server-side) is not affected by CVE-2025-23419
# and can still be used for performance
ssl_session_tickets off;
# Use server-side session cache instead
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
}
nginx -t && systemctl reload nginx
# Verify session tickets are disabled
echo | openssl s_client -connect api.example.com:443 -reconnect 2>&1 | \
grep -i "session ticket\|reused"
# Should NOT show "Session-ID:" being reused across connections
# Connection should show full handshake on reconnect
Step 5 — Verify mTLS is enforced after configuration change
# Test 1: Verify valid client cert is accepted
curl -v \
--cert /path/to/client.crt \
--key /path/to/client.key \
--cacert /path/to/ca.crt \
https://api-internal.example.com/health
# Expected: HTTP 200
# Test 2: Verify no client cert is rejected
curl -v \
--cacert /path/to/ca.crt \
https://api-internal.example.com/health
# Expected: TLS handshake failure or HTTP 400 (no required SSL certificate)
# Test 3: Verify wrong CA cert is rejected
curl -v \
--cert /path/to/wrong-ca-client.crt \
--key /path/to/wrong-ca-client.key \
--cacert /path/to/ca.crt \
https://api-internal.example.com/health
# Expected: SSL certificate verify result failure
# Test 4: Check session ticket key isolation between virtual hosts
# Use openssl to inspect session ticket details
echo | openssl s_client \
-connect api-internal.example.com:443 \
-servername api-internal.example.com \
-sess_out /tmp/session-a.pem 2>&1 | grep "Session-ID"
# Attempt to reuse on second virtual host (should fail for mTLS)
echo | openssl s_client \
-connect api-partner.example.com:443 \
-servername api-partner.example.com \
-sess_in /tmp/session-a.pem 2>&1 | grep "Reused\|verify error"
# With isolated keys: should NOT show "Reused, TLSv1.3"
Step 6 — Enforce per-connection certificate verification for critical paths
For the highest-security endpoints, disable session resumption on specific locations and require fresh certificate verification:
server {
listen 443 ssl;
server_name admin.example.com;
ssl_verify_client on;
ssl_client_certificate /etc/ssl/certs/admin-ca.crt;
ssl_session_ticket_key /etc/nginx/ssl/session-ticket-admin.key;
# For the most sensitive paths, enforce no caching
location /admin/critical/ {
# Force connection close after each request
# Prevents session reuse within the same connection
add_header Cache-Control "no-store";
# Verify the client cert is present and valid at the application layer
# Do not trust session-resumed connections for admin operations
if ($ssl_client_verify != "SUCCESS") {
return 403 "Client certificate required";
}
proxy_pass http://admin-backend;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Serial $ssl_client_serial;
}
}
Expected Behaviour
| Scenario | Unpatched / misconfigured | Patched or per-host keys |
|---|---|---|
| Session resumed on different virtual host | mTLS bypass — certificate not re-verified | Session ticket from vhost-a not valid for vhost-b |
| Revoked certificate with valid session ticket | OCSP check skipped; access granted | Full handshake required; OCSP check performed |
| Client with wrong-CA cert after session from correct-CA host | Bypass via session resumption | Rejected — session keys are isolated |
| Session ticket disabled entirely | N/A | Full handshake on every connection; no session state shared |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Per-virtual-host session ticket keys | Eliminates cross-host session reuse | Session resumption does not work across virtual hosts; slight performance overhead | Accept the overhead — correct mTLS enforcement outweighs session resumption savings |
| Disabling session tickets entirely | Eliminates the session ticket attack surface | TLS 1.3 0-RTT disabled; higher CPU on reconnection | Use server-side session cache (ssl_session_cache) as a performance partial substitute |
| Patching NGINX | Full fix; no configuration workaround needed | Requires deployment process; potential downtime | Use rolling restart (systemctl reload nginx does graceful reload without downtime) |
Checking $ssl_client_verify in application |
Defence-in-depth at app layer | Application changes required | Valuable even after patching — validates that the proxy chain is passing the verification result correctly |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Session ticket key file has wrong permissions | NGINX fails to start; error in nginx -t | nginx -t shows “no permission to open” |
chmod 600 /etc/nginx/ssl/session-ticket-*.key; chown root:root |
| Session ticket key rotation invalidates active sessions | Clients reconnect immediately; brief spike in TLS handshakes | Spike in NGINX connection rate; no errors | Expected behaviour during key rotation; rotate during low-traffic window |
| Disabling session tickets breaks a CDN or load balancer | CDN cannot maintain persistent sessions to NGINX origin | CDN reports increased origin load | Re-enable session tickets with per-host key isolation instead of disabling |
ssl_session_tickets off in nested context not applying |
Session tickets still used | openssl s_client -reconnect shows Reused |
Move the directive to http {} block, not server {} — some directives only apply at global context |
Related Articles
- NGINX mTLS Configuration — configuring mutual TLS correctly with NGINX, including certificate chain validation
- TLS Session Security — TLS session tickets, resumption, and the security trade-offs of each approach
- NGINX Worker Privilege Hardening — OS-level controls to contain damage when NGINX CVEs are exploited before patching
- Certificate Revocation OCSP Stapling — OCSP stapling and how session resumption can bypass revocation checks
- NGINX Fleet Patch Management — managing NGINX CVE patches across the fleet when emergency patching is needed