http lane inspects requests and responses. Envoy’s ext_authz already hands OPA the method, path and headers of a request, and the lane keeps that arrangement. It adds two things ext_authz cannot do by construction: it reads the response, which is where data leaves the building, and it keys policy on a stable resource identity instead of raw paths.
config.yaml
The normalized resource
Policy keyed on raw paths needs a regex per endpoint. The codec collapses dynamic segments to*, so /users/12345/orders/98765 becomes /users/*/orders/* and one http_resource rule covers the endpoint:
The slug survives on purpose: nothing distinguishes
/users/alice from /users/settings, and collapsing it would widen every rule written against either without warning. In doubt the codec keeps the segment, so a policy can end up too narrow but never too broad. File extensions survive too (/reports/12345.pdf → /reports/*.pdf), because a policy may allow *.csv and deny *.sql.
The http block
The defaults expose nothing: no bodies, no headers. Everything you capture reaches the policy engine, the audit trail and, where an analyzer is configured, a third party, so capture is opt-in per field.
Identity
identity_header names the header your authenticating proxy sets. The lane reads it on every request, because Envoy pools upstream connections and sends requests from different callers down one keep-alive connection. The first request names the first session before it opens, so the session_start audit row already carries the subject. A later request that names a different principal ends that session (session_end) and opens a new one on the same connection (session_start), so each request’s statement row and input.context.principal for OPA carry its own caller. A caller change while an earlier response on the connection is still outstanding is refused with a 403 under rule identity: that response belongs to the session that sent its request. A request without the header runs as anonymous and is forwarded, so a policy that requires a caller must deny input.context.principal == "anonymous" itself. The header value reaches policy input and the audit trail only when http.headers names it. A client that connects and sends no request within ten seconds is closed with no session and no session_start, the way a pgwire client that abandons its handshake is; HTTP has no server greeting, so waiting on the client’s first bytes is the protocol’s own ordering.
Trusting a header is safe only when nothing but that proxy can reach the listener, so bind loopback or a unix socket. On a listener reachable from anywhere else, a caller can assert any identity.
Google identity
google_identity names the caller from Google’s own bearer, for a lane with no authenticating proxy in front. kubectl through GKE Connect Gateway sends a Google OAuth2 access token on every request; without this block, Envoy would need ext_authz only to learn the name the token already holds.
- The lane verifies the request’s
Authorization: Bearertoken with a POST tohttps://oauth2.googleapis.com/tokeninfo. It never sends the token in a query string, because proxies log URLs. - The principal is the verified email, else the account’s Google
sub. - A verified answer is cached under the token’s SHA-256 for the token’s lifetime, at most five minutes. A rejected token is cached for 30 seconds. An unreachable Google is never cached.
- Any failure (no bearer, a token Google rejects, Google unreachable) refuses that request with a 403. A caller that cannot be verified is never served under the identity the connection had before.
- The token reaches no audit row, no policy input and no analyzer prompt.
httplanes only, and exclusive withidentity_header: startup refuses a lane that sets both.
trust.ca_file bundle, so it works through a proxy that re-signs egress TLS.
Caller token
caller_token admits a caller by a shared token, for a lane on a public URL with nothing in front to authenticate the caller. An agent on Claude Managed Agents or the OpenAI Agents API calls a lane this way.
- What is refused. No header, an empty or whitespace-only value, a value not in the file, or the header sent twice. Each gets a 403 and reaches no upstream. The refusal is audited under principal
anonymouswith ruleidentity. - Checked first. Policy judges a request after its body arrives, and the lane holds the request until then. The token is checked on the request headers, before the body. A caller without the token costs no held body, and a request whose body never arrives is refused too. This holds over HTTP/1.1 and HTTP/2.
- What is admitted. Every row of an admitted request names
nameas the principal. A token names no person, so per-user rows need a per-user token. - The token stays in the Sidecar. The Sidecar removes the header before the upstream. The token never reaches an audit row, policy input or an analyzer prompt.
- Refused at startup.
--validateand startup refuse anhttp.headersentry that names the token header. They also refuse aheaderthat the upstream, HTTP framing or the Sidecar already uses, such asauthorization,cookie,hostorvia. - Only the header is removed. An admitted caller that copies the token into another header, a chunked trailer or the body sends it on.
- The reserved route is guarded too.
/.well-known/hoop/answers only a caller the token admits. - Closed connections. The lane closes the connection when it cannot read a request head, when the client sends bytes before the server answers an
UpgradeorCONNECT, or when the codec cannot decode client bytes. After the server grants a switch, the tunnel is not checked again. - No TLS of its own. The token travels as written. Set
downstream_tls, or put TLS in front of the listener. httplanes only, and exclusive withidentity_headerandgoogle_identity.
a, b. The Sidecar keeps only SHA-256 digests in memory and compares every candidate with every token in constant time.
Rotation. The Sidecar checks the file every ten seconds and re-reads it when its size or modification time changes. SIGHUP re-reads it at once. A file that is missing, empty or malformed on a re-read keeps the tokens in force and logs one warning. To rotate without a refused request:
- Add the new token as a second line.
- Update every caller to the new token.
- Remove the old line.
--validate, stops the process with an error that names the file. The Control Plane never reads the file. A change to the caller_token block needs a restart.
From a Managed Agents vault. A vault credential can fill the token at egress, so the sandbox holds only a placeholder. See Claude Managed Agents. A request that reaches the Sidecar with the placeholder is refused like any wrong token. A vault rotation reaches running sessions on its own schedule, so rotate with the two-line overlap above.
From an OpenAI vault. The vault fills the placeholder anywhere in a request to the Sidecar’s host, the URL and the body included, and has no setting that limits where. Keep the token in its header. See Tokens go in headers only.
h2 and h2c
An http lane accepts HTTP/2 beside HTTP/1.1:
Each h2 stream is bridged into the unchanged HTTP/1.1 relay as one request, so policy, the per-stream 403, masking, audit, per-request identity and
Via apply to it exactly as to an HTTP/1.1 client. The upstream hop stays HTTP/1.1. Streams do not count toward max_conns: the client connection was admitted once, and HTTP/2’s concurrent-stream limit bounds its streams.
An h2 CONNECT, extended CONNECT included, gets 501. A request with an Upgrade header (kubectl exec, attach, port-forward) must reach the lane as HTTP/1.1. kubectl already does that when it talks to the lane directly; an Envoy with an h2 cluster to the lane needs a route that sends Upgrade requests to a second, HTTP/1.1 cluster. See The Envoy lane.
Via and loop detection
Every http lane appendsVia: <request version> hoop-<16 hex> as the last request header, so the upstream sees one extra header. The pseudonym is random per process. A request that already carries this process’s pseudonym is refused with a 403 whose message starts request loop:. That catches a transparent MITM proxy that routes the Sidecar’s own upstream traffic back into its listener, where each lap would otherwise look like a fresh client. After a request with Upgrade, or a CONNECT, the rest of that connection is another protocol and is no longer marked.
Masking
The codec rebuilds the response around the masked values. A JSON body is walked value by value, and each string or number reaches the masker under its dotted key path (data.password, items.metadata.name), so a columns rule names a JSON key the way it names a result-set column and masks it whatever it holds. A text body (text/*, XML, YAML, form data, or no Content-Type) is masked as text. Binary types pass through untouched.
Content-Length is corrected. A chunked body is re-chunked and streams: each complete top-level JSON value, or each line of text, goes out as soon as it is whole, so a watch or a log follow is not held to its end and a value split across two chunks is still masked whole. A gzip or deflate body is undone around the masker and compressed again. A text or JSON body under an encoding the codec cannot undo (zstd, br, lz4) is refused with a 403 and an error audit row rather than forwarded unmasked; a binary body under such an encoding passes. A body the codec is holding that grows past 16 MiB goes out unmasked, and the next response is masked again.
See Kubernetes API for columns: [data] against kube-apiserver’s chunked responses, and the ClickHouse HTTP interface for TSV, JSONEachRow and FORMAT JSON results.
A request leaves whole
From 1.258.0, the lane forwards an HTTP/1.1 or h2 request only once its body has arrived and policy has judged it. A denied request reaches no upstream, however the client split its bytes. An older Sidecar forwards the request line and the headers before the verdict. A route that acts on the headers alone then runs even when policy denies the request. What it costs:- A request larger than 8 MiB, headers and body together, is refused. Nothing of it reaches the upstream.
- A chunked body the lane cannot frame is refused at once with a 403. The lane would otherwise wait for it forever.
- A streaming upload reaches the upstream only once it ends.
- A client that sends
Expect: 100-continuesends the body only after its own timeout, 1 second by default in curl and in Go’shttp.DefaultTransport. The upstream has not seen the headers, so no100 Continuecomes back. curl sends the header for a body over 1 MiB, and-H 'Expect:'removes it. Go sends it only when the caller sets it. - A WebSocket message sent in fragments does not wait for its last fragment. Its earlier fragments reach the upstream before the message is judged.
Denials
A denied request returns403 Forbidden with the rule’s message and Connection: close:
The Envoy lane
Keep your existingext_authz filter, and OPA still answers reachability first. The one change is the route’s cluster, which points at the Sidecar instead of at the service:
envoy.yaml
httpbin lane in deploy/docker-compose/envoy-stack/ runs exactly this shape:
http2_protocol_options for ordinary requests, one plain HTTP/1.1 for upgrades. Match Upgrade first, because Envoy turns an upgrade on an h2 cluster into an extended CONNECT, which the lane answers with 501:
envoy.yaml
Next
Kubernetes API
kube-apiserver behind an http lane: header guardrails, masking by JSON key, kubectl exec.
Guardrail Rules
http_resource globs, http_status ranges, http_header and headers_not patterns, and deferring a match to Rego.Risk Analysis
What the lane’s analyzer sees with and without
capture_body, and what it costs.