Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, nginx stream, or HAProxy in mode 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-For for VOUCH_TRUSTED_PROXIES to 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:

SituationClient IP used
No trusted proxies configuredTCP peer address; X-Forwarded-For ignored
Peer is not in the trusted setTCP peer address; X-Forwarded-For ignored (fail closed)
Peer is trusted, header presentFirst right-to-left X-Forwarded-For entry outside the trusted set
Peer is trusted, header absent or emptyTCP peer address
Peer is trusted, every entry trustedTCP peer address
Peer is trusted, an entry is unparseableThe 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 Host is 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:

PortPurposeConfigurable
443HTTPSNo — fixed when TLS is configured
80HTTP→HTTPS redirect (308), plus /health and /health/readyNo
8443mTLS listener for certificate-bound tokensYes — 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.

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.

ListenerTarget groupCarries
TCP 443TCP 443HTTPS
TCP 80TCP 80HTTP, which Vouch answers with a 308 to HTTPS
TCP 8443TCP 8443mTLS, 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_PROXIES may 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 use send-proxy-v2 on the server line (send-proxy sends v1). In nginx stream use proxy_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 LOCAL header, 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: DISABLE above.
  • 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_PROXIES is 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_PROXIES covers your proxy ranges, and nothing wider
  • The proxy forwards the original Host
  • The proxy appends to X-Forwarded-For rather than replacing it

With the PROXY protocol:

  • VOUCH_PROXY_PROTOCOL=true, and VOUCH_TRUSTED_PROXIES lists 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_URL is 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/audit show real client IPs