TLS termination, passthrough and re-encryption
How the three proxy TLS topologies differ in visibility, certificate placement and client IP handling, with worked nginx configs and decision rules.
Key points
- Termination decrypts at the proxy, passthrough forwards encrypted bytes untouched (L4 only), re-encryption decrypts then opens a second TLS session upstream.
- Passthrough forces a layer 4 proxy, so there is no HTTP routing, no header injection and no HTTP health check, and the PROXY protocol becomes the only way to preserve the client IP.
- nginx defaults
proxy_ssl_verifytooffandproxy_ssl_server_nametooff, so a naive re-encryption config neither validates the upstream certificate nor sends SNI. - ALPN is negotiated at the terminating hop, so with passthrough the backend, not the proxy, decides whether the connection is HTTP/2.
A proxy can do one of three things with a TLS connection. Termination means the proxy holds the private key, completes the handshake with the client, and speaks cleartext HTTP to the backend. Passthrough means the proxy never decrypts anything: it copies TCP payload bytes between client and backend, and the backend holds the key. Re-encryption (also called TLS bridging) means the proxy terminates the client's TLS session, then opens a separate TLS session to the backend, so there are two independent handshakes, two certificate chains, and a plaintext moment inside the proxy's memory.
The choice is not primarily about encryption strength. It is about what the proxy is allowed to see, what it is capable of rewriting, and which layer it therefore operates at.
The three topologies compared#
| Termination | Passthrough | Re-encryption | |
|---|---|---|---|
| Proxy can read | Full request line, headers, body | Only TCP metadata, plus the plaintext ClientHello (SNI, ALPN, cipher list) | Full request line, headers, body |
| Proxy can rewrite | Anything: path, Host, headers, body | Nothing above TCP | Anything, on both legs independently |
| Where the serving certificate lives | Proxy | Backend | Proxy (front leg) and backend (upstream leg) |
| Backend sees client IP as | Proxy IP, so X-Forwarded-For is required | Proxy IP, so the PROXY protocol is required | Proxy IP, so X-Forwarded-For is required |
| HTTP routing (path, Host, method) | Yes | No | Yes |
| HTTP health checks | Yes | No, TCP connect or a TLS hello probe only | Yes |
| HTTP/2 negotiated by | Proxy via ALPN, independently per leg | Backend via ALPN, proxy is not involved | Proxy on both legs, independently |
| Handshake CPU cost | One handshake at the proxy | Zero at the proxy | Two handshakes, roughly double the asymmetric crypto |
| Per-byte cost after handshake | One decrypt | Byte copy, splice() eligible | Decrypt then re-encrypt |
| Plaintext exists at the proxy | Yes | No | Yes, briefly, in process memory |
| Typical compliance position | Acceptable if the proxy is inside the audited boundary | Required when the proxy operator must not be able to read payloads | Satisfies "encrypted in transit on every hop" wording |
The compliance row is where most arguments actually happen. Requirements such as PCI DSS's "encrypt cardholder data in transit over open, public networks" are satisfied by plain termination at the edge if everything behind the edge is a controlled network. Requirements phrased as encryption on every hop or no cleartext on any shared segment are what push teams to re-encryption. Requirements phrased as the platform team must not be technically capable of reading tenant traffic are what push teams to passthrough, because that is the only topology where the property is enforced by key custody rather than by policy.
Why passthrough forces a layer 4 proxy, and what that costs#
If the proxy cannot decrypt, everything it knows about the connection comes from the TCP tuple and from the one plaintext structure in a TLS session: the ClientHello. That yields the SNI hostname, the offered ALPN protocol list, the TLS version range and the cipher suites. It does not yield a path, a method, a Host header, a cookie or a JWT.
Four consequences follow, and they are the real price of passthrough:
- No HTTP routing. You can route on SNI (see SNI-based routing) but not on
/api/*versus/static/*. If your architecture assumes path-based fan-out at the edge, passthrough removes it. - No header injection. The proxy cannot add
X-Forwarded-For,X-Forwarded-Proto, a request ID, or an authenticated identity header. Anything you were relying on the edge to stamp must move to the backend. - No HTTP-level health checks. The proxy can open a TCP connection, and some proxies can send a TLS hello and check for a valid ServerHello (HAProxy's
option ssl-hello-chk), but it cannot ask for/healthzand read the status code. A backend whose TLS listener is up while the application pool is deadlocked will look healthy. This changes how you design health checks and upstream failover: the meaningful check has to be run by something that can decrypt, or by the backend reporting itself out of rotation. - The PROXY protocol becomes the only client IP mechanism. Because there is no HTTP header to add and no TLS record the proxy may modify, the only remaining option is to prepend an out-of-band header ahead of the TLS ClientHello, which is exactly what the PROXY protocol does. The backend must be configured to expect it, from a restricted set of source addresses, and it must not accept it from anywhere else. If you need to check what a given header looks like on the wire, the PROXY protocol decoder parses both v1 and v2 frames.
Re-encryption, and the two very different versions of it#
Re-encryption looks like a single configuration decision but it has two sub-cases with completely different security properties.
Verified re-encryption. The proxy validates the upstream certificate: it checks the chain against a specified CA (usually an internal CA, not the public roots), checks the name, checks expiry, and ideally checks revocation. An attacker who is in-path between proxy and backend cannot impersonate the backend, because they do not have a certificate the proxy will accept. This is the version that provides what people think re-encryption provides.
Unverified re-encryption. The proxy encrypts to whatever answers on the upstream address and accepts any certificate. The traffic is encrypted, so a passive tap sees ciphertext. But an active in-path attacker simply presents a self-signed certificate and the proxy accepts it. Against the threat model that motivated the second TLS session in the first place, this is security theatre: it defeats passive eavesdropping only.
nginx's defaults land you in the second case unless you act:
| Directive | nginx default | Effect of the default |
|---|---|---|
proxy_ssl_verify | off | Upstream certificate is not validated at all |
proxy_ssl_verify_depth | 1 | Even when verification is on, the accepted chain is short. nginx documents this only as "the verification depth", so match it to your real chain and confirm with a test handshake |
proxy_ssl_server_name | off | No SNI is sent upstream, so a multi-tenant backend serves its default certificate |
proxy_ssl_name | $proxy_host | The name checked (and sent, if SNI is on) comes from proxy_pass, not from the client's Host |
proxy_ssl_session_reuse | on | Upstream sessions are resumed, which is what you want |
The proxy_ssl_server_name off default is the one that produces the most confusing incident. You turn on proxy_ssl_verify on for the first time, and the handshake fails with a name mismatch, because without SNI the upstream returned a certificate for an unrelated default vhost. The fix is to enable both, not to turn verification back off.
HAProxy is explicit about the same distinction: server app1 10.0.0.1:443 ssl alone does not verify, and the configuration parser will warn about it; ssl verify required ca-file /etc/ssl/internal-ca.pem sni str(app.internal) is the verified form. Envoy requires an explicit validation_context in an UpstreamTlsContext before it will trust anything, which makes the unverified case harder to reach by accident.
Session resumption, ticket keys and a proxy fleet#
Session resumption removes the asymmetric operation from repeat handshakes, which is where most of the CPU goes. There are two mechanisms and they behave differently behind a load balancer.
Session IDs are server-side state. Each terminating node has its own cache (nginx ssl_session_cache shared:SSL:10m;), so a client that lands on node B cannot resume a session established on node A. With round-robin distribution across N nodes, the resumption hit rate degrades roughly as 1/N.
Session tickets (RFC 5077, and the ticket-based mechanism that TLS 1.3 uses for all resumption) are client-side state encrypted under a key held by the server. If every node in the fleet holds the same ticket key, any node can resume any session. nginx implements this with ssl_session_ticket_key file; where the file holds 80 bytes of random data (48 bytes selects the older AES128 format). Without it, each nginx worker process generates its own key at startup, so tickets do not survive a reload, let alone a move to another host.
The operational rule: distribute ticket keys, and rotate them. A shared static ticket key that is never rotated undermines forward secrecy, because anyone who obtains the key can decrypt every recorded session that resumed under it. The standard pattern is three keys in the file, with the first used for encryption and the rest accepted for decryption, rotated on a schedule shorter than the ticket lifetime (ssl_session_timeout, default 5 minutes in nginx).
Under TLS 1.3 the ticket is issued after the handshake completes, and 0-RTT early data (nginx ssl_early_data, default off) rides on the resumed key. Leave it off unless the application is genuinely idempotent on the affected routes, because 0-RTT data is replayable by design.
OCSP stapling belongs to the terminating hop#
Only the hop that presents a certificate can staple a revocation response for it, because the stapled OCSP response is carried in the status_request extension of that hop's own handshake. Under passthrough, stapling is the backend's job and the proxy has no involvement. Under termination or re-encryption, the front proxy staples for the public certificate, and the backend staples for the internal one (which internal clients usually do not check).
In nginx this needs three things together, and missing the third is the usual cause of silent non-stapling:
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/chain-with-root.pem;
resolver 10.0.0.2 valid=300s;nginx fetches the OCSP response lazily, after the first handshake that would have used it, so openssl s_client -status immediately after a reload legitimately shows no stapled response. Query it twice before concluding stapling is broken.
Where HTTP/2 is decided#
ALPN (RFC 7301) is negotiated inside the TLS handshake. The hop that completes the handshake with the client is the hop that picks the protocol.
- Termination and re-encryption: the proxy advertises what it supports (
h2,http/1.1) to the client, and separately negotiates with the upstream. A common and perfectly valid result is HTTP/2 on the front leg and HTTP/1.1 on the back leg, because the proxy translates. In nginx you opt into upstream HTTP/2 explicitly with the gRPC module, or withproxy_http_version 2on 1.29.4 and later;proxy_http_version 1.1gets you HTTP/1.1 keep-alive instead. - Passthrough: the proxy is not a TLS endpoint, so it does not see ALPN as a negotiation, only as bytes it can read in the ClientHello. The backend chooses. If your backend does not advertise
h2, no amount of edge configuration will produce HTTP/2. The details of protocol version handling on each leg are covered in HTTP/2 and HTTP/3 through proxies.
HTTP/3 sharpens this: QUIC's handshake is TLS 1.3 carried in QUIC packets over UDP, so a TCP passthrough proxy cannot pass it through at all. HTTP/3 at the edge is always terminated.
Worked example: nginx passthrough with ssl_preread#
The stream module reads the ClientHello without terminating, exposes the SNI name, and routes on it. The module must be compiled in (--with-stream_ssl_preread_module), which is why ssl_preread on; sometimes fails with "unknown directive" on a distro build.
stream {
map $ssl_preread_server_name $upstream_pool {
api.example.com api_backend;
payments.example.com payments_backend;
default api_backend;
}
upstream api_backend {
server 10.0.1.10:443 max_fails=3 fail_timeout=10s;
server 10.0.1.11:443 max_fails=3 fail_timeout=10s;
}
upstream payments_backend {
server 10.0.2.10:443;
}
server {
listen 443;
ssl_preread on;
proxy_pass $upstream_pool;
proxy_protocol on; # backend must expect and trust this
proxy_timeout 10m; # default is 10m; raise for long-lived streams
}
}Observable behaviour: the backend's access log shows the real client address only if it is configured with set_real_ip_from <proxy>; real_ip_header proxy_protocol; and its own listen 443 ssl proxy_protocol;. If the backend is not expecting the PROXY header, the first bytes of the connection are PROXY TCP4 ... where a ClientHello was expected, and the backend logs a TLS parse error rather than anything that mentions the PROXY protocol. That mismatch, in both directions, is the single most common passthrough failure.
Worked example: nginx terminating and re-encrypting, done properly#
server {
listen 443 ssl;
http2 on; # nginx 1.25.1+ directive form
server_name app.example.com;
ssl_certificate /etc/ssl/public/app.pem;
ssl_certificate_key /etc/ssl/private/app.key;
ssl_protocols TLSv1.2 TLSv1.3; # the default since nginx 1.23.4
ssl_session_cache shared:SSL:10m;
ssl_session_ticket_key /etc/ssl/ticket/current.key;
location / {
proxy_pass https://app_upstream;
# The four directives that turn theatre into verification
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/ssl/internal-ca.pem;
proxy_ssl_verify_depth 2;
proxy_ssl_server_name on;
proxy_ssl_name app.internal.example.com;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_ssl_session_reuse on;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}The upstream leg now fails closed. If the backend certificate expires, nginx logs upstream SSL certificate verify error: (10:certificate has expired) while SSL handshaking to upstream and returns 502, which is a correct and detectable failure rather than a silent downgrade.
Decision rules#
Work down this list and stop at the first match.
- The proxy operator must be technically incapable of reading the payload (multi-tenant platform, regulated tenant data, customer-managed keys): passthrough. Accept the loss of L7 features and budget for the PROXY protocol rollout.
- The backend already terminates TLS and owns its certificate lifecycle (a vendor appliance, a SaaS origin, a Kubernetes ingress you do not control): passthrough, or re-encryption if you need L7 routing more than you need key custody.
- The network between proxy and backend is shared, untrusted, or crosses an availability zone boundary you do not control: re-encryption, with
proxy_ssl_verify onagainst an internal CA. Unverified re-encryption is worth doing only if your threat model is passive capture. - You need WAF, caching, request rewriting, header-based auth, path routing or per-request observability: termination or re-encryption. Passthrough is off the table; do not try to fake L7 features at L4.
- Everything else, single trusted network segment: terminate at the edge and speak plain HTTP to the backend. It is the cheapest option, it halves your handshake cost, and the certificate lifecycle lives in one place.
A note on the middle ground: it is common and sensible to mix topologies on one edge. Terminate for the main web property, pass through for the endpoint that needs client certificates (see mutual TLS through a proxy for why terminating that one is a bigger decision than it looks), and re-encrypt for the payments path. SNI routing on a stream listener in front of the HTTP listener is the usual way to arrange it.
Failure modes#
| Symptom | Root cause | Fix |
|---|---|---|
unknown directive "ssl_preread" at config test | The stream preread module is not compiled in | Rebuild with --with-stream_ssl_preread_module or use a package that includes it |
| Backend logs a TLS handshake error on every connection; no HTTP requests arrive | proxy_protocol on at the proxy but the backend listener does not expect it | Add proxy_protocol to the backend listen line, or remove it from the proxy |
| Backend sees the proxy IP as the client, with passthrough in place | PROXY protocol not enabled, or backend has no set_real_ip_from for the proxy | Enable on both sides and set the trusted source correctly |
upstream SSL certificate verify error: (20:unable to get local issuer certificate) | proxy_ssl_verify on without proxy_ssl_trusted_certificate, or the chain is incomplete | Point at the internal CA bundle; make the backend serve its intermediates |
Verification fails with a name mismatch after enabling proxy_ssl_verify | proxy_ssl_server_name is off by default, so the upstream returned its default vhost certificate | Set proxy_ssl_server_name on and an explicit proxy_ssl_name |
| Handshake rate and CPU jump after adding a second edge node | Session ID cache is per node and tickets are not shared | Deploy a shared ssl_session_ticket_key and rotate it |
Client negotiates HTTP/1.1 despite http2 on at the edge | Passthrough is in effect, so the backend's ALPN list is what matters | Advertise h2 at the backend, or terminate at the edge |
openssl s_client -status shows no OCSP response right after reload | nginx fetches the OCSP response lazily on first use | Retry; if still absent, check resolver and ssl_trusted_certificate |
| Passthrough backend marked healthy while the app returns 500 to everyone | TCP-only health check cannot see HTTP status | Move the meaningful check to a decrypting hop or have the app deregister itself |
Frequently asked questions#
Is TLS passthrough more secure than termination?#
It is more secure against one specific threat: a compromised or curious proxy operator, because the proxy never holds the keys or the plaintext. It is not more secure in general. Passthrough removes the ability to run a WAF, to enforce header hygiene, to strip inbound spoofed identity headers and to rate limit on request attributes, all of which are real defences you give up.
Does re-encryption count as end-to-end encryption?#
No. Re-encryption produces two encrypted segments with a decryption point between them, so the proxy sees plaintext. It satisfies audit language about traffic being encrypted on every network hop, but it does not satisfy a claim that only the client and the application can read the data. Only passthrough gives you that.
Why does nginx not verify the upstream certificate by default?#
proxy_ssl_verify off is the default because nginx cannot guess which CA should be trusted for an internal upstream. Enabling verification by default against the system root store would break almost every deployment that uses an internal CA or a self-signed backend certificate. The default optimises for the configuration working on the first try, which is why turning it on is a deliberate step.
Can I do path-based routing with TLS passthrough?#
No. The path lives inside the encrypted record layer, and a passthrough proxy cannot decrypt it. The only routing key available before the first application byte is the SNI hostname from the ClientHello, plus the TCP tuple and the ALPN list. If you need path routing, you must terminate.
How do I preserve the client IP with passthrough?#
Use the PROXY protocol. The proxy prepends a small header describing the original source and destination addresses ahead of the TLS ClientHello, and the backend parses it before the handshake. Configure the backend to accept it only from the proxy's addresses, because a backend that accepts it from anywhere lets any client assert an arbitrary source IP.
Do I need to share TLS session ticket keys across my proxy fleet?#
Yes, if you have more than one terminating node and clients are distributed across them without affinity. Without shared keys, each node can only resume sessions it created, and every cross-node request pays a full handshake. Generate an 80-byte key file, distribute it to all nodes, and rotate it on a schedule so a stolen key does not compromise historical sessions indefinitely.
Where should OCSP stapling be configured in a proxy chain?#
On whichever hop presents the certificate to the client. With termination or re-encryption that is the front proxy; with passthrough it is the backend. A proxy cannot staple a response for a certificate it does not serve, because the stapled response travels in the same handshake as the certificate itself.
Does HTTP/3 work through a passthrough proxy?#
Not through a TCP passthrough proxy, because HTTP/3 runs over QUIC on UDP. You would need a UDP forwarder, and even then connection migration and the QUIC connection ID make naive load balancing unreliable. In practice HTTP/3 is terminated at the edge and translated to HTTP/2 or HTTP/1.1 upstream.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
- RFC 6066: TLS Extensions: Extension Definitions
- RFC 7301: TLS Application-Layer Protocol Negotiation Extension
- RFC 6960: X.509 Internet PKI Online Certificate Status Protocol
- RFC 5077: TLS Session Resumption without Server-Side State
- nginx ngx_stream_ssl_preread_module
- nginx ngx_http_proxy_module
- nginx ngx_http_ssl_module
- HAProxy configuration manual
- The PROXY protocol specification
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.