Enforcing mTLS
Pattern: this is where the policy becomes real. The two configurations below, one on the calling side, one on the serving side, are what turn "both ends should authenticate" into "no valid certificate, no connection." Everything before this page was setup; this is the enforcement.
Recall the two ends of an internal connection: the WAF calls the backend. The WAF must present its client certificate and verify the backend's server certificate; the backend must present its server certificate and require the WAF's client certificate. Both halves have to be configured, or the check is one-sided.
The calling side (WAF)
On the WAF, inside the location block that proxies to the backend:
location / {
proxy_pass https://service.int.example;
# Verify the backend's server certificate against the CA
proxy_ssl_server_name on;
proxy_ssl_name service.int.example;
proxy_ssl_trusted_certificate /etc/ssl/certs/example-ca.crt;
proxy_ssl_verify on;
proxy_ssl_verify_depth 2;
# Present the WAF's own client certificate (this is the "mutual" half)
proxy_ssl_certificate /etc/ssl/certs/waf-to-service.crt;
proxy_ssl_certificate_key /etc/ssl/private/waf-to-service.key;
}
Line by line, what each directive enforces:
proxy_pass https://…: the upstream is HTTPS, not HTTP. The connection to the backend is itself TLS, separate from the public TLS the user terminated at the WAF.proxy_ssl_server_name on/proxy_ssl_name: send the backend's name during the handshake and verify the certificate matches it. This is what makes the name the identity: the WAF checks it reachedservice.int.exampleand not something else answering on that IP.proxy_ssl_trusted_certificate: the CA root. The backend's certificate is trusted only if it chains to this.proxy_ssl_verify on: actually enforce the check. Without this, nginx would happily connect to a backend presenting any certificate. This directive is the difference between "encrypted" and "verified."proxy_ssl_verify_depth 2: allow the chain to be two links deep (host cert → intermediate → root), matching the CA's structure.proxy_ssl_certificate/_key: the WAF's own client certificate. This is the half that makes it mutual: the WAF proves its identity to the backend, not just the other way around.
The serving side (backend)
On the backend host, the internal gateway server block:
server {
listen 443 ssl;
server_name service.int.example;
# This host's server certificate
ssl_certificate /etc/ssl/certs/service.crt;
ssl_certificate_key /etc/ssl/private/service.key;
# Require and verify the caller's client certificate
ssl_client_certificate /etc/ssl/certs/example-ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
# hand off to the local application
proxy_pass http://127.0.0.1:80;
proxy_set_header Host $host;
# ...forwarding headers
}
}
The three directives that do the enforcing:
ssl_client_certificate: the CA root, here used to validate incoming client certificates. The backend trusts a caller whose certificate chains to this.ssl_verify_client on: the critical one. This makes the client certificate mandatory. A connection without a valid CA-signed client certificate is rejected at the TLS layer, before the request ever reaches the application. This single directive is what enforces "being on the network isn't enough."ssl_verify_depth 2: same two-link chain allowance as the other end.
Note the shape: the backend terminates the mutually-authenticated TLS, then hands the request to the local application over plain localhost HTTP (127.0.0.1:80). The application itself doesn't need to know anything about certificates; the gateway enforces mTLS in front of it. That keeps the application simple and puts all the certificate logic in one place per host.
Gotchas, in context
The name and the certificate must agree
The single most common self-inflicted failure: the WAF's proxy_ssl_name and the backend's server_name (and the name in the backend's certificate) all have to refer to the same thing. If the WAF connects under one name and the backend's server block answers to another, nginx selects the wrong server block or rejects the handshake, and you get a misdirected-request error that looks like a proxy bug but is really a name mismatch. When adding a service to a host that already serves others, the name the caller uses is what routes it, get that wrong and the symptom is confusing out of proportion to the cause.
ssl_verify_client on is the load-bearing line
It's easy to set up all the certificates, get encryption working, and never actually turn on ssl_verify_client. The connection will be encrypted and will appear to work, but it isn't mutual, and any host on the network can connect. Encryption without verification is the trap; the verification directive is the security control. Confirm it's on, and confirm it's rejecting certless connections, rather than assuming.
A reissued shared certificate breaks everything at once
Covered in detail on the previous page, but it surfaces here, as a TLS handshake failure: if a host's shared server certificate is reissued and the chain is disturbed, every mutually-authenticated connection to that host fails simultaneously with an access-denied alert, surfaced by the proxy as a misdirected-request error. When all sites on a host break at the same moment right after a certificate change, this is the first thing to suspect, not the individual site you were working on.
Adapt this for…
Any internal service-to-service connection, not just WAF-to-backend. The pattern generalizes: the caller presents a client cert and verifies the callee's server cert; the callee presents a server cert and requires the caller's client cert. Both ends reference the same CA root. Anywhere two internal components talk and you want that traffic authenticated rather than merely encrypted, this is the shape, and ssl_verify_client on (or its equivalent on the serving side) is always the line that actually enforces it.