Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

10.5 TLS Passthrough with SNI Routing

The party trick that makes security teams smile: route encrypted traffic by hostname without holding a single private key. Then the adult conversation: when not to.

Levels: Advanced ~35 min read

Advanced

What you will learn

  1. Terminate vs. passthrough — the decision, not the technology.

  2. ssl_preread: reading the ClientHello's SNI field without decrypting anything.

  3. The routing pattern: map $ssl_preread_server_name → variable proxy_pass — a single listener on :9243, verified with two certificates.

  4. What you give up when you don't terminate (and the hybrid that recovers part of it).

  5. ssl_preread's other exports and the mode's sharp edges.


1. Terminate or pass through?

Module 6 terminated TLS: NGINX held the certificate, decrypted, and became an L7 citizen — rewrites, caches, WAF, all of it. Passing through keeps the bytes encrypted and keeps you blind: the decision table you'll actually use in production:

Factor

Terminate (Module 6)

Passthrough (this lesson)

Private keys live on

the proxy (guard them well)

only the backends

L7 features (paths, headers, caches)

all available

none — bytes are bytes

Client certificates (mTLS)

NGINX verifies, can inject headers

backend's job, end to end

TLS session cache / tickets

NGINX's, one place

per backend — resumption gets flaky behind balancing

Protocol upgrades (h2/h3)

negotiated per hop

client↔backend negotiate directly — h3 stays h3

Latency

decrypt + re-encrypt (µs, one more hop's crypto)

~L4 proxy cost only

Cert rotation

one place (the proxy)

every backend

Typical passthrough tenants: gRPC/mTLS estates where the backend must see the client cert, Kafka with SASL/SCRAM, database TLS, and anything where "the load balancer cannot see plaintext" is a compliance line someone drew on a whiteboard.

2. ssl_preread — reading without decrypting

TLS's ClientHello — the very first message the client sends — is sent in the clear. Inside it, the SNI extension (Server Name Indication, the same field Module 6's certs were named for) carries the ASCII hostname the client wants to reach, because that's how a shared-IP server picks a certificate before any crypto happens.

ssl_preread on; tells the stream module: peek at the ClientHello just far enough to extract SNI (and a few neighbors), then stop parsing and pipe the bytes — including the ClientHello itself — untouched to the upstream. NGINX never sees a key, never decrypts a byte, and can't: it simply read a field the client shouted in plaintext anyway.

The export vocabulary:

Variable

Content

$ssl_preread_server_name

the SNI hostname (empty if the client sent none)

$ssl_preread_alpn_protocols

client's ALPN list ( h2,http/1.1) — route by protocol, not just name

$ssl_preread_protocol

the TLS version of the ClientHello — your TLS-1.2-only-backend gatekeeper

3. The routing pattern

The :9243 router, complete and commented as taught:

nginx
# route by hostname — no ssl_certificate anywhere in sight
map $ssl_preread_server_name $tls_backend {
    api.example.test   127.0.0.1:9100;   # b-tls-a (holds the api cert)
    alt.example.test   127.0.0.1:9101;   # b-tls-b (holds the alt cert)
    default            127.0.0.1:9100;
}

server {
    listen       9243;
    ssl_preread  on;              # peek at SNI, then get out of the way
    proxy_pass   $tls_backend;    # variable proxy_pass = per-session routing
}

Two things make this work, and both are load-bearing:

  1. proxy_pass with a variable — the one from Lesson 10.2's "or a variable" aside, now doing real work. A variable target means no upstream group (no retry chain — a failed connect is a dead session, Lesson 10.2's receipts apply) and no proxy_next_upstream; the map chose, the session goes.

  2. The map + default — the router's entire control plane. An SNI-less client (old TLS libraries, some health checks) yields an empty $ssl_preread_server_name, which matches no case and falls to default — always define one.

Proof (real capture — note NGINX holds no certs; each backend served its own, and verification through the tunnel succeeded):

text
$ openssl s_client -connect 127.0.0.1:9243 -servername api.example.test -CAfile certs/ca.crt
    subject=C=OO, O=Example Org, CN=api.example.test
    issuer=C=OO, O=Example Org, CN=Example Org Root CA
    Verify return code: 0 (ok)

$ openssl s_client -connect 127.0.0.1:9243 -servername alt.example.test -CAfile ...
    subject=C=OO, O=Example Org, CN=alt.example.test
    Verify return code: 0 (ok)

$ openssl s_client -connect 127.0.0.1:9243 -servername unknown.name.test -CAfile ...
    subject=C=OO, O=Example Org, CN=api.example.test      # <- map default

Same port, same NGINX, zero decryption — three hostnames, three destinations. The unknown.name.test line is the map's default proving itself: unmatched SNI still gets a backend (never a black hole — unless your default says so).

4. What you gave up, and the hybrid

The honest costs of blindness:

  • No L7 anything: no paths, no header hygiene (Module 4), no WAF (Module 7), no caching (Module 8), no JSON logs of requests (Module 12 will log these as bare sessions).

  • Load visibility shrinks to connect/bytes/session-time — enough for capacity, not for debugging.

  • TLS resumption + balancing fight: session tickets are keyed per backend, and a resumed session pins to wherever the ticket came from. hash $ssl_preread_server_name (all names to one peer) or shared ticket keys on backends are the escapes.

  • Client-cert flows work only because the backend terminates; the proxy can't even see that mTLS happened.

The hybrid that production actually runs — SNI routing first, termination where policy allows:

nginx
map $ssl_preread_server_name $target {
    payments.example.com  127.0.0.1:9443;   # passthrough: PCI zone, own certs
    default               127.0.0.1:9444;   # re-encrypt to the L7 tier
}

Port 9444 is a Module 6-style terminating stream server ( listen 9444 ssl; ssl_certificate …) that re-encrypts into the fleet — one NGINX, both postures, chosen per hostname.

5. Sharp edges

  1. Variable proxy_pass means no group — retries and max_fails quarantine (Lesson 10.2) are gone. If a backend dies, sessions fail until the map changes. (Static upstream + proxy_pass can't be mixed per-name without stream's upstream-on-variable limits — Module 13's dynamic-resolver pattern is the scalable answer.)

  2. SNI is optional — health checkers and ancient clients send none; your default answers them. Route accordingly.

  3. SNI is unauthenticated — any client can claim any name; the certificates do the authenticating at the backend. Don't use SNI alone as access control.

  4. ALPN routing needs the whole list — $ssl_preread_alpn_protocols is a comma-joined blob ( "h2,http/1.1"); map with regexes ( ~^h2 …), not equality.

  5. One ssl_preread per listen — it's a server-level on/off, not per-upstream. Mixed estates belong in separate server{} blocks (the hybrid above).

Try it yourself

  • openssl s_client -connect 127.0.0.1:9243 -servername <name> — api → api cert, alt → alt cert, unknown → default: all three routing cases, one command each (§3's capture shows the expected output).

  • The setup: b-tls-a/ b-tls-b — two example backends holding different certs from the same CA — that's what makes routing provable with Verify return code: 0.

  • Experiment: remove the default line and connect with -servername "" — observe the failure shape (an unroutable session), then restore it.

Q&A

Q: Can passthrough do HTTP/3? A: QUIC is UDP — listen … udp + ssl_preread doesn't apply the same way (the QUIC ClientHello is encrypted! QUIC's SNI rides inside the TLS-in-QUIC handshake, and NGINX's OSS preread doesn't decode it). h3 passthrough needs a QUIC-aware relay — worth knowing before you promise it.

Q: How is this different from stream{} TLS termination? A: Termination ( listen … ssl; ssl_certificate …) makes NGINX an endpoint that then speaks plaintext to backends. Passthrough never ends the TLS session — it routes one. Different trust boundaries, different feature sets.

Q: What does the backend see as the client? A: NGINX — Lesson 10.3's problem, now wearing TLS. Backends that need the real IP must accept PROXY protocol inside nothing — you can't prepend plaintext to a TLS stream. The answers are transparent proxying (kernel) or in-protocol metadata (SNI itself, client certs, or the app's own headers post-termination).

Takeaways

  • ssl_preread on; peeks at the plaintext ClientHello for SNI (and ALPN/protocol), then pipes the TLS stream untouched — routing without keys, without decryption, without liability.

  • The pattern is map $ssl_preread_server_name → variable proxy_pass; always define a default, because SNI-less clients exist and empty-string routing is real.

  • You trade all of L7 for keylessness — the hybrid (passthrough-for-some, terminate-and-re-encrypt-for-the-rest) is the production norm.

  • Variable proxy_pass has no retry/quarantine semantics — failure handling moves to your map's freshness, which is exactly the problem Module 13 returns to solve with dynamic resolution.

Was this page helpful?

Mark this learning resource complete to track your learning progress.