Behind a Reverse Proxy
Most deployments put something in front of the Vouch server: an AWS Network Load Balancer, nginx, HAProxy, a Kubernetes ingress controller. This chapter covers what has to be configured for that to work correctly, and the one setting whose absence degrades security silently.
Terminate TLS in Vouch where you can
The recommended topology is TCP passthrough with TLS terminated inside Vouch, through a load balancer that keeps the client’s address or a proxy that sends it with the PROXY protocol (see Supported topologies), not TLS terminated at the proxy.
Vouch pins a BCP 195 cipher suite list, prefers hybrid post-quantum key exchange, and hosts the mTLS listener used for certificate-bound tokens. Terminating at the proxy replaces all of that with whatever the proxy negotiates, and breaks RFC 8705 certificate-bound tokens outright, because the client certificate never reaches Vouch.
Terminate at the proxy only when something else forces it — a corporate WAF requirement, or a managed load balancer that cannot pass TCP through, such as an AWS Application Load Balancer. If you do, everything below still applies.
Supported topologies
Vouch supports three:
- Pass-through that keeps the client’s address: an L4 load balancer that forwards the TCP connection with the client’s own source address, such as an AWS NLB with client IP preservation (see below). Vouch terminates TLS and serves the mTLS port itself.
- Pass-through with the PROXY protocol: a TCP proxy that opens its own connection to Vouch and
sends the client’s address in a PROXY protocol v2 header ahead of the relayed TLS, such as an
Istio or Envoy gateway with TLS
PASSTHROUGH, nginxstream, or HAProxy inmode tcp. Vouch terminates TLS and serves the mTLS port itself. See PROXY protocol. - TLS terminated at an HTTP proxy, which reports the client’s address in
X-Forwarded-ForforVOUCH_TRUSTED_PROXIESto read. This gives up certificate-bound tokens.
A TCP proxy that opens its own connection to Vouch without the PROXY protocol is not supported.
Vouch then sees the proxy as every request’s client, so all users share one rate-limit bucket and
audit events record the proxy’s address. With TLS opaque to the proxy there is no
X-Forwarded-For to fall back on.
Trusted proxies
This is the setting to get right.
VOUCH_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
VOUCH_TRUSTED_PROXIES is a comma-separated list of CIDR ranges holding your proxies. It controls
how Vouch decides a request’s client IP, which in turn drives rate limiting and the client IP
recorded on audit events.
It applies to the HTTPS port only. On the mTLS port (8443) Vouch terminates TLS itself, so no proxy
can add a header there, and X-Forwarded-For is always ignored: the client IP is the TCP peer, or
the PROXY header’s source when VOUCH_PROXY_PROTOCOL is on.
With VOUCH_PROXY_PROTOCOL on, the same list names the proxies allowed to send the PROXY header,
and X-Forwarded-For is not read on any port. See PROXY protocol.
When it is unset (the default), X-Forwarded-For is ignored completely and the TCP peer
address is used as the client IP. Behind a proxy, that peer address is the proxy. Every user
therefore shares a single rate-limit bucket, and every audit event records the load balancer’s
address instead of the user’s.
Nothing warns you about this. The server starts cleanly, requests succeed, and the only symptom is that a moderately busy deployment starts returning 429s to everyone at once, with audit records that cannot attribute anything to anyone.
When it is set, Vouch walks X-Forwarded-For from right to left and takes the first address
that is not in the trusted set — the RFC 7239 rightmost-trusted algorithm. This is the only
approach resistant to a client spoofing extra X-Forwarded-For entries: injected values sit to the
left of the addresses your own proxies appended, so the walk stops before reaching them.
The behavior in full:
| Situation | Client IP used |
|---|---|
| No trusted proxies configured | TCP peer address; X-Forwarded-For ignored |
| Peer is not in the trusted set | TCP peer address; X-Forwarded-For ignored (fail closed) |
| Peer is trusted, header present | First right-to-left X-Forwarded-For entry outside the trusted set |
| Peer is trusted, header absent or empty | TCP peer address |
| Peer is trusted, every entry trusted | TCP peer address |
| Peer is trusted, an entry is unparseable | The walk stops there and the peer address is used |
List only the ranges your proxies actually occupy. Trusting an over-broad range lets anything inside it forge a client IP. An invalid CIDR is a fatal startup error, not a warning.
Verify it by checking that audit events at /admin/audit show real client addresses rather than
your load balancer’s.
Host header validation
Vouch validates the Host header against the configured rp_id. A request arriving with a
mismatched host gets 421 Misdirected Request.
This matters because WebAuthn credentials are cryptographically bound to the RP ID. Accepting an
arbitrary Host would let a request through under a name the browser will not honor at
authentication time.
Configure your proxy to pass the original host through:
- nginx —
proxy_set_header Host $host; - HAProxy — preserved by default
- AWS NLB (TCP passthrough) — no HTTP layer, so
Hostis never rewritten - Kubernetes ingress-nginx — preserved by default
A blanket 421 across every request almost always means the proxy is rewriting Host to the
backend’s address. On the HTTP→HTTPS redirect listener, the redirect target is built from the
configured rp_id rather than the incoming header, so a mismatched host cannot be used to bounce
users to another origin.
Ports
When TLS is configured, the listen ports are fixed and VOUCH_LISTEN_ADDR is ignored:
| Port | Purpose | Configurable |
|---|---|---|
| 443 | HTTPS | No — fixed when TLS is configured |
| 80 | HTTP→HTTPS redirect (308), plus /health and /health/ready | No |
| 8443 | mTLS listener for certificate-bound tokens | Yes — VOUCH_MTLS_PORT |
Without TLS configured, the server listens on VOUCH_LISTEN_ADDR (default [::]:3000) and none of
the above applies. That is the mode to use when the proxy terminates TLS.
The mTLS listener starts automatically whenever TLS is configured — there is no flag to disable it. Firewall rules and security groups that open only 80 and 443 will silently break certificate-bound tokens. See TLS, Ports, and mTLS.
Binding to 80 and 443 requires CAP_NET_BIND_SERVICE on Linux. The RPM and DEB packages set this
up; a bind failure on port 80 is logged as a warning and is not fatal, so a deployment can lose its
HTTP redirect without otherwise failing.
Timeouts and body limits
Vouch applies a 10-second global request timeout and a 256 KiB global body limit, with tighter per-route limits (8 KiB for credential issuance, 64 KiB for SCIM and SAML ACS). Set your proxy’s timeouts at or above 10 seconds so that Vouch, not the proxy, produces the timeout response; and do not set a body limit below Vouch’s, or you will convert precise 413s into opaque proxy errors.
The request timeout only starts once a request has arrived. Before that, a listener that takes the
PROXY protocol closes a connection whose PROXY header has not fully arrived within 5 seconds,
and every listener closes a connection that has not finished its TLS handshake within 5 seconds, has not started sending a
request within 10 seconds, or has not completed an HTTP/1 request head within 10 seconds of
it starting to arrive. The last limit also runs between requests, so an idle keep-alive connection
is closed after 10 seconds. An HTTP/2 connection is closed once it has had no request in flight for
10 seconds, which also covers a client that stops partway through a request’s headers. A proxy that pools keep-alive
connections to Vouch (for example nginx with keepalive in an upstream block) must close idle
upstream connections sooner than that (keepalive_timeout below 10 seconds in nginx), or it can
send a request on a connection just as Vouch closes it and answer the client with a 502.
Vouch also caps open connections: 64 per client address (VOUCH_MAX_CONNECTIONS_PER_IP,
counting IPv6 clients per /64) and 10,000 in total (VOUCH_MAX_CONNECTIONS). A proxy that
terminates TLS connects from its own address on behalf of every client, so list it in
VOUCH_TRUSTED_PROXIES: trusted proxies are exempt from the per-address cap. Without that, the cap
applies to the proxy itself and it will see refused connections under load. With NLB passthrough
and client IP preservation, or with the PROXY protocol, the cap sees each real client and needs no
configuration. An address taken from a PROXY header is never exempt, even one inside
VOUCH_TRUSTED_PROXIES: the header’s source is the client, not the proxy. The proxy’s own address
stays exempt wherever it is the connection’s peer: on port 80, which never takes the PROXY
protocol, and on a LOCAL health-check header. On the mTLS port a peer that sends no PROXY header is
never exempt: Vouch terminates TLS there, so the peer is the client, whatever its address.
Port 80 serves only the HTTPS redirect and readiness probes, and draws from a pool of its own: at
most 256 open connections, apart from VOUCH_MAX_CONNECTIONS. Connections on port 80 cannot take
the places the HTTPS and mTLS listeners need.
Example configurations
nginx (TLS terminated at the proxy)
server {
listen 443 ssl;
server_name auth.example.com;
ssl_certificate /etc/nginx/certs/auth.example.com.pem;
ssl_certificate_key /etc/nginx/certs/auth.example.com.key;
location / {
proxy_pass http://10.0.1.10:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}
On the Vouch side, leave VOUCH_TLS_CERT and VOUCH_TLS_KEY unset, set
VOUCH_LISTEN_ADDR=0.0.0.0:3000, set VOUCH_BASE_URL=https://auth.example.com so issued URLs use
the public scheme and host, and set VOUCH_TRUSTED_PROXIES to the nginx host’s range.
AWS Network Load Balancer (TCP passthrough — recommended)
An NLB with TCP listeners forwards bytes untouched, so Vouch terminates TLS itself and keeps its cipher policy, its hybrid post-quantum key exchange, and its mTLS listener. Prefer it to an Application Load Balancer, which is HTTP-aware and always terminates TLS: that gives up all three and breaks RFC 8705 certificate-bound tokens, because the client certificate never reaches Vouch.
Use three TCP listeners, each forwarding to a TCP target group on the same port. A TLS target group would terminate TLS at the load balancer instead.
| Listener | Target group | Carries |
|---|---|---|
| TCP 443 | TCP 443 | HTTPS |
| TCP 80 | TCP 80 | HTTP, which Vouch answers with a 308 to HTTPS |
| TCP 8443 | TCP 8443 | mTLS, for certificate-bound tokens |
NLB listeners have no redirect action of their own, which is why port 80 forwards to the instances and Vouch issues the redirect itself. Omit the 8443 listener only if you do not use certificate-bound tokens.
Health-check the 443 target group with protocol HTTPS and path /health/ready. The health
check’s Host header is the load balancer node’s IP rather than your domain. That passes, because
Vouch validates Host only on the port 80 redirect listener, and /health/ready is exempt from
that check. Checking 443 exercises the TLS listener as well as the database; port 80 serves
/health/ready too, but a check there cannot notice a TLS listener that has stopped answering.
On the Vouch side, set VOUCH_TLS_CERT and VOUCH_TLS_KEY, set VOUCH_BASE_URL to the public
HTTPS URL, and leave VOUCH_TRUSTED_PROXIES unset.
TCP passthrough has no X-Forwarded-For, so the TCP peer address is the only client identity Vouch
sees and VOUCH_TRUSTED_PROXIES cannot recover it. AWS enables client IP preservation by default
for instance targets, but disables it for IP targets on TCP and TLS target groups. When it is off,
every request appears to come from a load balancer node: all users share one rate-limit bucket and
audit events record no real client address. Set preserve_client_ip.enabled to true on IP target
groups.
If cross-zone load balancing is disabled, register a target in every Availability Zone the load balancer has a node in. A zone whose node has no local target does not fail fast — it holds the connection for several seconds before falling back to another zone, which surfaces as intermittent multi-second latency rather than as an error.
PROXY protocol
A TCP proxy that relays TLS without terminating it opens its own connection to Vouch, so the TCP
peer Vouch sees is the proxy. It cannot add X-Forwarded-For to ciphertext. Instead it sends the
client’s address in a PROXY protocol
header ahead of the relayed bytes, and Vouch reads it before the TLS handshake. This keeps Vouch’s
cipher policy, its post-quantum key exchange, the mTLS listener and RFC 8705 certificate-bound
tokens.
Turn it on, and name the ranges your proxies connect from in VOUCH_TRUSTED_PROXIES:
VOUCH_PROXY_PROTOCOL=true
VOUCH_TRUSTED_PROXIES=10.244.0.0/16
It applies to the HTTPS listener (443) and the mTLS listener (VOUCH_MTLS_PORT, 8443) together.
On both:
- Every connection must start with a PROXY protocol v2 header. A connection without one is closed. There is no auto-detection: the spec forbids guessing whether the header is present, so a listener with the PROXY protocol on cannot also serve clients that connect directly.
- Only
VOUCH_TRUSTED_PROXIESmay send it. A connection from any other address is closed before anything is read from it. Anyone who can reach the listener from inside a listed range can claim any client address, so list only your proxies, and restrict who can reach the port (below). - Version 2 (binary) only. The text v1 format is refused. AWS NLB sends v2, and Istio/Envoy send
it with
proxyProtocol: { version: V2 }. In HAProxy usesend-proxy-v2on theserverline (send-proxysends v1). In nginxstreamuseproxy_protocol v2;, available from nginx 1.31.4;proxy_protocol on;sends v1. - The header must arrive within 5 seconds of the connection, then the TLS handshake gets its usual 5 seconds.
- A
LOCALheader, which a proxy sends for its own health checks, is accepted, and the proxy’s own address is used as the client.
The header’s source address is then the client for rate limiting, the audit client_ip, and the
per-address connection cap. X-Forwarded-For is not read: the proxy relays TLS it cannot add the
header to, so any X-Forwarded-For in the request came from the client.
VOUCH_PROXY_PROTOCOL applies only when TLS is configured, and needs VOUCH_TRUSTED_PROXIES; the
server refuses to start without either. Port 80 never takes the PROXY
protocol, and serves /health/ready so that probes which cannot send the header, such as the
kubelet’s, still have a readiness endpoint.
Istio ingress gateway on Kubernetes
The path this is built for:
client ──TLS──▶ AWS NLB ──PROXY v2──▶ Istio ingress gateway ──PROXY v2 + raw TLS──▶ Vouch pod :443 / :8443
(ip targets) (TLS PASSTHROUGH, SNI routing) (no sidecar on these ports)
1. NLB to gateway. Have the NLB send PROXY v2 to the gateway pods, and have the gateway read
it. On the gateway Service
(AWS Load Balancer Controller annotations):
service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: "*"
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
On the gateway pods (gateway network topology):
proxy.istio.io/config: '{"gatewayTopology" : { "proxyProtocol": {} }}'
That annotation makes the gateway expect the header on every TCP listener it has, so give Vouch a dedicated gateway.
2. Gateway routing. SNI passthrough, one server per port (SNI passthrough):
apiVersion: networking.istio.io/v1
kind: Gateway
metadata: { name: vouch }
spec:
selector: { istio: vouch-gateway }
servers:
- port: { number: 443, name: tls, protocol: TLS }
tls: { mode: PASSTHROUGH }
hosts: [auth.example.com]
- port: { number: 8443, name: tls-mtls, protocol: TLS }
tls: { mode: PASSTHROUGH }
hosts: [auth.example.com]
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: { name: vouch }
spec:
hosts: [auth.example.com]
gateways: [vouch]
tls:
- match: [{ port: 443, sniHosts: [auth.example.com] }]
route: [{ destination: { host: vouch, port: { number: 443 } } }]
- match: [{ port: 8443, sniHosts: [auth.example.com] }]
route: [{ destination: { host: vouch, port: { number: 8443 } } }]
3. Gateway to Vouch. A DestinationRule makes the gateway prepend the header
(DestinationRule reference):
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: { name: vouch }
spec:
host: vouch
trafficPolicy:
tls: { mode: DISABLE } # no mesh mTLS on this hop; Vouch's own TLS protects it
proxyProtocol: { version: V2 }
4. Keep the Vouch pod’s sidecar off 443 and 8443. On the Vouch pod template (annotations):
traffic.sidecar.istio.io/excludeInboundPorts: "443,8443"
Both halves of that are required:
- Mesh mTLS breaks it. Envoy writes the PROXY header to the raw socket before the mesh TLS
handshake starts, so with mesh mTLS on the Vouch pod’s sidecar receives the header where it
expects a ClientHello and fails every connection. Hence
tls.mode: DISABLEabove. - A sidecar in the path without mesh mTLS is unsafe. The sidecar would relay the bytes, but the
TCP peer Vouch sees would become the sidecar (
127.0.0.6). Vouch would have to trust that address as a PROXY sender, and any pod that can reach the Vouch pod would have its forged header relayed from it. Excluding the ports takes the sidecar out of the path.
sidecar.istio.io/inject: "false" also works, but it takes Vouch’s outbound traffic (database,
upstream IdP) out of the mesh too.
5. Restrict who can send the header. Vouch sees the gateway pods’ IPs, so set
VOUCH_TRUSTED_PROXIES to the pod CIDR the gateway pods run in. Any other pod in that CIDR
could forge a header, so add a NetworkPolicy that admits only the gateway pods to Vouch’s 443 and
8443:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: vouch-ingress }
spec:
podSelector: { matchLabels: { app: vouch } }
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: istio-ingress } }
podSelector: { matchLabels: { istio: vouch-gateway } }
ports: [{ port: 443 }, { port: 8443 }]
- ports: [{ port: 80 }] # readiness probes and the HTTP redirect
Port 80 stays open to every pod, and a pod inside VOUCH_TRUSTED_PROXIES is exempt from the
per-address cap there. Port 80 draws from its own pool of 256 connections, so such a pod can hold up
redirects and probes on port 80 but cannot take places the HTTPS and mTLS listeners need.
6. Probes. Point the readiness probe at port 80, which never takes the PROXY protocol. The kubelet sends no header, so a probe on 443 would be refused:
readinessProbe:
httpGet: { path: /health/ready, port: 80 }
livenessProbe:
httpGet: { path: /health, port: 80 }
Verify it end to end: requests from outside the NLB should show the client’s address in audit
events at /admin/audit, and a PROXY header sent to Vouch from a pod other than the gateway should
be refused.
Checklist
With TCP passthrough:
- Client IP preservation is on for the target group, and
VOUCH_TRUSTED_PROXIESis unset - Listeners exist for 443, 80, and 8443 if you use certificate-bound tokens
- Health checks use HTTPS on 443, not HTTP on 80
With TLS terminated at the proxy:
-
VOUCH_TRUSTED_PROXIEScovers your proxy ranges, and nothing wider - The proxy forwards the original
Host - The proxy appends to
X-Forwarded-Forrather than replacing it
With the PROXY protocol:
-
VOUCH_PROXY_PROTOCOL=true, andVOUCH_TRUSTED_PROXIESlists only the proxies’ ranges - The proxy sends PROXY protocol v2
- Nothing but the proxy can reach 443 and 8443 (security group,
NetworkPolicy) - Probes that cannot send the header use port 80
Either way:
- Health checks target
/health/ready, not/health -
VOUCH_BASE_URLis the public URL clients use - Proxy timeouts are at least 10 seconds
- A proxy that pools upstream keep-alive connections closes idle ones in under 10 seconds
- A proxy that terminates TLS is listed in
VOUCH_TRUSTED_PROXIES, so the per-address connection cap does not apply to it - Audit events at
/admin/auditshow real client IPs