TLS & security

SNI-based routing

How proxies route TLS connections without decrypting, by reading SNI from the ClientHello: nginx, HAProxy, Envoy and Traefik configs, plus what ECH breaks.

· 15 min read · How we verify this

Key points

  • SNI is sent in the plaintext ClientHello in both TLS 1.2 and TLS 1.3, which is why a layer 4 proxy can route on hostname without holding any private key.
  • The proxy must buffer the first record and delay the routing decision, so every SNI router has an inspect delay (tcp-request inspect-delay in HAProxy) that fails closed when too short.
  • Encrypted Client Hello replaces the real hostname with the client-facing server's public name, so SNI routing sees only the public name and per-tenant routing behind it stops working.
  • When a proxy terminates TLS it should reject requests whose Host header does not match the negotiated SNI, because the mismatch is the basis of domain fronting.

SNI-based routing lets a proxy pick a backend for a TLS connection without decrypting it. The Server Name Indication extension (RFC 6066) carries the hostname the client intends to reach, in the clear, inside the first message of the handshake. A layer 4 proxy peeks at that ClientHello, extracts the name, chooses an upstream, then forwards every byte of the connection unmodified, including the ClientHello it just read. It holds no private key and terminates nothing.

This is what makes multi-tenant TLS passthrough practical: one IP address and one port can front hundreds of backends that each hold their own certificate.

What the proxy actually parses#

The proxy needs the SNI value, which sits four layers of framing deep. It must parse enough of each to find the next.

text
TLS record layer
  0x16                       ContentType = handshake
  0x03 0x01                  legacy record version (always TLS 1.0 on the first record)
  0x?? 0x??                  record length, big-endian, max 16384
  |
  +-- Handshake message
        0x01                 HandshakeType = client_hello
        0x?? 0x?? 0x??       length, 3 bytes
        0x03 0x03            legacy_version (always TLS 1.2, even for TLS 1.3)
        32 bytes             random
        1 + n                legacy_session_id
        2 + n                cipher_suites
        1 + n                compression_methods
        2 + n                extensions
              |
              +-- Extension type 0x0000 = server_name
                    2 bytes  server_name_list length
                    0x00     NameType = host_name
                    2 bytes  host_name length
                    n bytes  the hostname, ASCII, no trailing dot, no port

Three details in that layout cause real bugs.

The version fields lie on purpose. The record layer version is 0x0301 and legacy_version inside the ClientHello is 0x0303, regardless of whether the client will end up negotiating TLS 1.3. TLS 1.3 moved the real version list into the supported_versions extension precisely because middleboxes choked on unfamiliar version numbers in those fields. Code, or a firewall rule, that decides "this is TLS 1.0, block it" from the record header is wrong for every modern connection.

SNI is plaintext in TLS 1.3 too. TLS 1.3 encrypts the server's certificate and most extensions after the ServerHello, but the ClientHello itself is sent before any key agreement exists, so server_name remains readable. This is not an oversight; it is what allows the server to select a certificate. Encrypting it required a separate mechanism, which is ECH.

A ClientHello can exceed one record. With large key shares (post-quantum hybrids in particular) and long ALPN or session ticket data, the ClientHello may be split across TCP segments or, in rarer cases, across multiple records. A proxy that reads once and gives up will fail to find SNI. This is the mechanical reason every implementation has a buffering or delay knob.

Implementation comparison#

nginx (stream)HAProxyEnvoyTraefik
Enable inspectionssl_preread on;tcp-request inspect-delay <t> plus a content ruleenvoy.filters.listener.tls_inspectorAutomatic for TCP routers
SNI accessor$ssl_preread_server_namereq.ssl_sniserver_names in filter_chain_matchHostSNI(name) rule
Other preread data$ssl_preread_protocol, $ssl_preread_alpn_protocolsreq.ssl_ver, req.ssl_alpn, req.ssl_hello_typeapplication_protocols, transport_protocolALPN in TLS options
Wildcard supportVia map with *.example.com keysACL with -m end .example.comLeading wildcard *.example.com onlyHostSNIRegexp
No-SNI defaultmap default entryif !{ req.ssl_sni -m found }Filter chain with no server_namesHostSNI(*) rule
Timeout knobpreread_timeout, default 30stcp-request inspect-delaylistener_filters_timeout, default 15srespondingTimeouts
Build requirement--with-stream_ssl_preread_moduleBuilt inBuilt inBuilt in

$ssl_preread_alpn_protocols deserves a mention beyond hostname routing: it lets a single port separate HTTP/2, HTTP/1.1 and raw protocols before any decryption, which is how people put gRPC and web traffic on 443 with different backends.

Worked configs#

nginx stream with ssl_preread and a map#

nginx
stream {
    map $ssl_preread_server_name $backend {
        hostnames;                       # enables leading-wildcard keys
        api.example.com        api_pool;
        *.tenants.example.com  tenant_pool;
        legacy.example.com     legacy_pool;
        default                fallback_pool;
    }

    upstream api_pool     { server 10.0.1.10:443; server 10.0.1.11:443; }
    upstream tenant_pool  { server 10.0.2.10:443; }
    upstream legacy_pool  { server 10.0.3.10:443; }
    upstream fallback_pool{ server 10.0.0.9:443; }

    log_format sni '$remote_addr [$time_local] sni="$ssl_preread_server_name" '
                   'proto=$ssl_preread_protocol -> $upstream_addr $status';
    access_log /var/log/nginx/stream.log sni;

    server {
        listen 443;
        ssl_preread on;
        preread_timeout 5s;              # default is 30s
        proxy_pass $backend;
        proxy_protocol on;
    }
}

The hostnames parameter in map is what makes *.tenants.example.com behave as a wildcard rather than a literal key. Without it, the wildcard entry silently never matches and every tenant lands in fallback_pool. Because passthrough leaves no way to inject headers, proxy_protocol on is the only mechanism for the backend to learn the client address; see the PROXY protocol for the backend side.

HAProxy with req.ssl_sni#

haproxy
frontend tls_in
    bind :443
    mode tcp
    tcp-request inspect-delay 5s
    tcp-request content accept if { req.ssl_hello_type 1 }

    use_backend api_pool     if { req.ssl_sni -i api.example.com }
    use_backend tenant_pool  if { req.ssl_sni -m end -i .tenants.example.com }
    use_backend no_sni_pool  if !{ req.ssl_sni -m found }
    default_backend fallback_pool

backend api_pool
    mode tcp
    server a1 10.0.1.10:443 check send-proxy-v2
    server a2 10.0.1.11:443 check send-proxy-v2

The pairing of inspect-delay with tcp-request content accept if { req.ssl_hello_type 1 } is the important idiom. The accept rule releases the connection as soon as a complete ClientHello has arrived, so the delay is an upper bound rather than a fixed cost. Omit the accept rule and every connection waits the full 5 seconds before HAProxy evaluates the use_backend lines.

Envoy with the TLS inspector#

yaml
listeners:
- name: tls_443
  address: { socket_address: { address: 0.0.0.0, port_value: 443 } }
  listener_filters:
  - name: envoy.filters.listener.tls_inspector
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector
  filter_chains:
  - filter_chain_match:
      transport_protocol: tls
      server_names: ["api.example.com"]
    filters:
    - name: envoy.filters.network.tcp_proxy
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
        stat_prefix: api
        cluster: api_cluster
  - filter_chain_match:
      transport_protocol: tls
      server_names: ["*.tenants.example.com"]
    filters:
    - name: envoy.filters.network.tcp_proxy
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
        stat_prefix: tenants
        cluster: tenant_cluster
  - filter_chain_match: {}            # no server_names: catches no-SNI and unmatched
    filters:
    - name: envoy.filters.network.tcp_proxy
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
        stat_prefix: fallback
        cluster: fallback_cluster

Envoy's matching is by specificity, not by document order: an exact server_names entry beats a wildcard, which beats a chain with no server_names. That is a genuine difference from nginx map and HAProxy ACLs, where order matters, and it means you cannot force a catch-all to win by putting it first. If a connection matches no chain at all, Envoy increments downstream_listener_filter_error or closes the connection, and the failure appears as a connection reset with no log line at the application layer. More on listener structure in Envoy listeners, routes and clusters.

Traefik TCP routers with HostSNI#

yaml
tcp:
  routers:
    api:
      entryPoints: ["websecure"]
      rule: "HostSNI(`api.example.com`)"
      service: api-svc
      tls:
        passthrough: true
    tenants:
      entryPoints: ["websecure"]
      rule: "HostSNIRegexp(`^.+\\.tenants\\.example\\.com$`)"
      service: tenant-svc
      tls:
        passthrough: true
  services:
    api-svc:
      loadBalancer:
        servers: [{ address: "10.0.1.10:443" }]
    tenant-svc:
      loadBalancer:
        servers: [{ address: "10.0.2.10:443" }]

Traefik enforces a rule that catches a common misunderstanding: a TCP router without TLS may only use HostSNI(`*`), because there is no SNI to match on a plain TCP connection. Attempting a specific hostname on a non-TLS TCP router is a configuration error, not a rule that silently never matches. Router precedence and the rest of the model are covered in Traefik routers, services and middlewares.

SNI and Host header mismatch: domain fronting#

SNI selects the certificate during the handshake. The HTTP Host header (or the :authority pseudo-header in HTTP/2) selects the resource after the handshake. Nothing in either specification requires them to match, and that gap is domain fronting: a client sends SNI: allowed.example.com so that a network filter or a passthrough router sees an approved name, then sends Host: blocked.example.net inside the encrypted stream so the terminating server routes it somewhere else entirely.

The rule is asymmetric and worth stating precisely:

  • A passthrough SNI router cannot detect the mismatch. It never sees the Host header. Anyone treating SNI as an access control decision at layer 4 is relying on client cooperation.
  • A terminating proxy can and should detect it. Once you decrypt, both values are available, and comparing them is cheap.

In nginx, $ssl_server_name holds the negotiated SNI and $host holds the normalised Host, so the check is a two-line guard:

nginx
if ($ssl_server_name != $host) {
    return 421;   # Misdirected Request, per RFC 9110
}

421 is the correct status here rather than 400: RFC 9110 defines it for a request directed at a server that cannot produce a response for the target URI, and clients are permitted to retry on a fresh connection. Be aware that HTTP/2 connection coalescing makes legitimate mismatches possible when one certificate covers several names and the client reuses the connection, so enforce this only where you know the certificate and name set, or restrict the comparison to names outside your own certificate's SAN list.

Wildcard matching semantics#

Wildcard behaviour is not uniform, and the differences bite during migrations.

  • Certificates (RFC 6125): the wildcard must be the leftmost label, and it matches exactly one label. *.example.com matches a.example.com and does not match example.com or a.b.example.com.
  • nginx map with hostnames: supports a leading *.example.com and a trailing www.example.*. Unlike the certificate rule, nginx's asterisk matches several name parts, so *.example.com covers a.b.example.com as well as a.example.com. It does not cover the bare example.com; the combined form .example.com is the one that matches both the apex and its subdomains.
  • Envoy server_names: supports a leading wildcard only, in the form *.example.com, and it does not match the bare domain. Bare * is not a wildcard entry; a chain with no server_names is the catch-all.
  • HAProxy: has no wildcard syntax as such; you build it with match methods, -m end -i .example.com for suffix and -m beg for prefix. Note the leading dot in the suffix pattern, without it notexample.com matches too.
  • Traefik: exact match with HostSNI, or a regular expression with HostSNIRegexp.

The recurring migration bug is moving a rule from nginx map to Envoy server_names and losing the apex domain, because the nginx entry was written as .example.com, which covers example.com as well as its subdomains, and its nearest Envoy equivalent *.example.com does not.

No SNI at all, and the default backend#

SNI is optional. You will see connections without it from:

  • clients connecting to a literal IP address, since SNI must be a DNS name and never a bare address
  • health checkers and scanners that open TLS without a hostname
  • very old TLS stacks, and some embedded and industrial devices
  • deliberate probes looking for what your default certificate reveals about your infrastructure

Every SNI router therefore needs a defined no-SNI path. There are three sane choices: send it to a dedicated backend that serves a minimal default certificate and a static error, close the connection, or send it to the primary backend. The one to avoid is sending it to the first-defined backend by accident, because that backend then presents its certificate for a request that asked for nothing, and the client shows a name mismatch error that is confusing to diagnose from the client end. Whichever you pick, log it: a sudden rise in no-SNI connections is a reliable early signal of a misconfigured client rollout or a scanner.

Failure modes#

SymptomRoot causeFix
All connections land on default_backend despite correct hostnamestcp-request inspect-delay missing entirely, so req.ssl_sni evaluates before any data arrivesAdd tcp-request inspect-delay 5s and the req.ssl_hello_type 1 accept rule
Routing works from a local test, fails for real users on lossy linksInspect delay too short, ClientHello split across TCP segments and not fully arrivedRaise the delay to 5s and keep the accept rule so the cost is only paid when needed
Every connection stalls for exactly the inspect-delay valueThe accept rule is missing, so HAProxy always waits the full delayAdd tcp-request content accept if { req.ssl_hello_type 1 }
Clients get a certificate for the wrong siteNo SNI sent, so the default backend or default certificate answersDefine an explicit no-SNI backend; verify with openssl s_client -connect host:443 without -servername
Wildcard tenants all hit the fallback pool in nginxhostnames parameter missing from the map blockAdd hostnames; as the first line of the map
Apex domain broken after migrating nginx to EnvoyNeither nginx's *.example.com nor Envoy's matches the bare example.com; the nginx config relied on the combined .example.com form, which Envoy has no equivalent forAdd an explicit server_names: ["example.com"] chain
Connection reset, no access log entry, EnvoyNo filter chain matched, so the connection is dropped before any network filter runsAdd a chain with empty filter_chain_match; check listener.<name>.no_filter_chain_match
Firewall drops "TLS 1.0" connections that are really TLS 1.3Rule reads the record-layer version, which is fixed at 0x0301 for compatibilityRead supported_versions or drop the rule
SNI routing works, then breaks for a subset of clients after a browser updateThose clients enabled ECH, so the visible SNI is now the public nameExpect it: route the public name to a backend that can complete the ECH handshake

What Encrypted Client Hello changes#

ECH (specified in the draft-ietf-tls-esni series, still a draft at the time of writing but shipping behind flags and increasingly by default in Firefox and Chrome when the DNS HTTPS record advertises a key) encrypts the real ClientHello. The client builds an inner ClientHello with the true server_name, encrypts it under a public key published in the DNS HTTPS resource record, and wraps it in an outer ClientHello whose server_name is the public name of the client-facing server.

The consequence for SNI routing is direct and unavoidable: the proxy sees the public name, not the tenant name. ECH is designed to defeat exactly the observation that SNI routing depends on. There is no configuration that recovers the inner name without the ECH private key.

What operators should expect and plan for:

  • ECH is opportunistic. It requires an HTTPS DNS record containing an ech parameter, and it requires DNS resolution the client trusts (typically DNS over HTTPS). If you do not publish the record, clients do not use ECH against you, so nothing changes today for most self-hosted estates.
  • If you are behind a CDN that publishes ECH keys on your behalf, the CDN is the client-facing server and decrypts the inner ClientHello. Your own SNI routing sits behind that decryption point and continues to work, because the CDN re-originates.
  • A single-tenant passthrough router keyed on one hostname is unaffected in practice, because the public name and the real name are the same.
  • A multi-tenant passthrough router keyed on tenant hostnames is the case that breaks. The long-term answer is to terminate at the ECH-aware hop and route on the decrypted name, which means moving from passthrough to termination or re-encryption as described in TLS termination, passthrough and re-encryption.
  • SNI-based egress filtering and "do not decrypt" bypass lists degrade to the public name as well, which is a policy problem for corporate proxies before it is a problem for reverse proxies.

There is also a retry path worth knowing about: if the server cannot decrypt the inner ClientHello, it can complete the handshake with the public name and return retry_configs, prompting the client to retry with fresh keys. Rejected ECH attempts therefore surface as extra handshakes and a ech_required alert, not as silent failure.

Frequently asked questions#

Can a proxy read SNI without the private key?#

Yes. SNI travels in the ClientHello, which is sent before any keys are established, so it is plaintext on the wire in TLS 1.2 and TLS 1.3 alike. The proxy parses the record layer and handshake framing, extracts the server_name extension, and routes on the value. Nothing about this requires a certificate or a key.

Is SNI encrypted in TLS 1.3?#

No. TLS 1.3 encrypts the server certificate and most handshake extensions after the ServerHello, but the ClientHello itself, including SNI, is still sent in the clear. Encrypting it is the job of a separate extension, Encrypted Client Hello, which is a work in progress rather than part of RFC 8446.

Why is tcp-request inspect-delay needed in HAProxy?#

Because ACLs are evaluated as soon as a rule set is reached, and at connection accept time no client data has arrived, so req.ssl_sni would be empty. The inspect delay tells HAProxy to wait for data before evaluating content rules. Pair it with tcp-request content accept if { req.ssl_hello_type 1 } so the wait ends the moment a complete ClientHello arrives.

What happens when a client sends no SNI?#

The proxy has no hostname to route on and falls back to whatever default you configured, or to an implementation-defined default if you configured none. Direct-to-IP connections, old clients and health checkers routinely send no SNI, so define the fallback explicitly and log it rather than letting an arbitrary backend answer with the wrong certificate.

Does ECH break SNI-based routing?#

Yes, by design. With ECH the outer ClientHello carries the public name of the client-facing server and the real hostname is encrypted inside, so a passthrough router sees only the public name. The only way to route on the real name is to hold the ECH key and decrypt, which means terminating rather than passing through.

How do I test which backend an SNI name routes to?#

Use openssl s_client -connect proxy.example.com:443 -servername target.example.com and inspect the certificate the server returns; the subject and issuer tell you which backend answered. Repeat without -servername to exercise the no-SNI path. For a passthrough router the returned certificate is the backend's own, which makes it an unambiguous signal.

Should I block requests where SNI and Host disagree?#

If you terminate TLS, comparing them and returning 421 for a mismatch is a reasonable default, since domain fronting depends on that gap. Be careful with HTTP/2 connection coalescing, which legitimately reuses one connection for several names covered by the same certificate, so scope the check to names outside your certificate's SAN list.

Can I route on ALPN as well as SNI?#

Yes. nginx exposes $ssl_preread_alpn_protocols (available since 1.13.10), HAProxy exposes req.ssl_alpn, and Envoy matches on application_protocols in the filter chain. This lets a single 443 listener send h2 traffic to a gRPC backend and http/1.1 to a web backend before any decryption happens.

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.

  1. RFC 6066: TLS Extensions: Extension Definitions
  2. RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
  3. TLS Encrypted Client Hello (draft-ietf-tls-esni)
  4. nginx ngx_stream_ssl_preread_module
  5. nginx ngx_stream_map_module
  6. HAProxy configuration manual
  7. Envoy TLS Inspector listener filter
  8. Envoy FilterChainMatch
  9. Traefik TCP routers

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.

More in tls and proxy security#