BuildGateways
Envoy ext_authz
Configure Envoy ext_authz to obtain Chio allow or deny decisions for services behind Envoy or Istio.
Why Use ext_authz
Every other Chio integration targets a single runtime, framework, or language. ext_authz operates at the proxy boundary. One adapter can evaluate requests for services in a mesh that runs Envoy as the data plane, regardless of the service's language, deployment model, or framework. Istio uses ext_authz natively via AuthorizationPolicy CUSTOM actions; Consul Connect via envoy_extensions.ext_authz; AWS App Mesh, Gloo, standalone Envoy, and Cilium's L7 policy layer all support the same filter.
The protocol fit is almost exact. Envoy expects the external service to accept a request, return allow or deny, and optionally inject headers. Chio's evaluation engine already produces exactly this shape, so teams can configure an existing Envoy deployment to call the adapter. The proxy remains the only sidecar needed by the workload.
The ext_authz Protocol
Envoy's external authorization filter intercepts every request (or a configured subset) and sends a check request to an external service before forwarding upstream. That service returns allow or deny, with optional header mutations applied to the request going upstream or to the response going back to the client.
Two Transport Modes
ext_authz can talk to the external service over gRPC or HTTP. chio-envoy-ext-authz serves the gRPC path (HTTP/2, binary protobuf) through the generated envoy.service.auth.v3.Authorization service. Configure the filter with grpc_service; see Why the Filter Uses grpc_service below.
Envoy Filter Configuration
A minimum gRPC-mode filter chain wiring Chio into Envoy:
http_filters:
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
grpc_service:
envoy_grpc:
cluster_name: chio_ext_authz
timeout: 0.25s
with_request_body:
max_request_bytes: 8192
allow_partial_message: true
pack_as_bytes: true
allowed_headers:
patterns:
- exact: authorization
- exact: x-request-id
- prefix: x-chio-
# Fail-closed: deny if chio is unreachable
failure_mode_allow: false
include_peer_certificate: true
clusters:
- name: chio_ext_authz
type: STRICT_DNS
lb_policy: ROUND_ROBIN
# envoy_grpc needs an HTTP/2 upstream.
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
load_assignment:
cluster_name: chio_ext_authz
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: 127.0.0.1, port_value: 9091 }Fail-closed is the default
failure_mode_allow to true only for non-security-critical workloads where availability outweighs enforcement.Mapping ext_authz to Chio
The ext_authz CheckRequest carries the downstream request's attributes. The adapter projects these onto Chio's protocol-agnostic request model: method and path map directly; Authorization and x-chio-capability-token headers produce caller identity and capability id; the request body is hashed to SHA-256 and never stored in full; the source principal (mTLS peer certificate or SPIFFE ID) becomes the caller subject; Envoy's request id flows through as request_id for correlation.
Capability Token Transport
The adapter derives caller identity from four sources, checked in order. The first one present wins:
- x-chio-capability-token (preferred, explicit): raw capability token id issued by chio, mapped to
AuthMethod::Capability. - Authorization: Bearer <token>: standard bearer flow, mapped to
AuthMethod::Bearer. The token is reduced to a SHA-256 digest immediately; the raw value never leaves the translation layer. - mTLS peer principal: when Envoy reports a downstream peer certificate, its SPIFFE URI or subject DN becomes the caller subject via
AuthMethod::Mtls. - If none is present, the request is evaluated under
AuthMethod::Anonymous. Your policy decides whether anonymous callers are allowed.
The adapter never forwards raw secrets upstream. It strips authorization and x-chio-capability-token from the forwarded header map, and reduces bearer tokens and request bodies to SHA-256 hex digests before they cross the translation boundary, consistent with Chio's never-store-secrets policy.
Verdict to CheckResponse
chio Verdict::Allow
-> CheckResponse { status: OK (0) }
+ OkHttpResponse { headers: [] } # no header mutations
+ dynamic_metadata { "chio.verdict": "allow" }
chio Verdict::Deny { reason, guard, http_status }
-> CheckResponse { status: PermissionDenied (7) }
+ DeniedHttpResponse {
status: StatusCode(<http_status>), # nearest Envoy code, default 403
headers: [
{ "x-chio-denial-reason": "<reason>" },
{ "x-chio-denial-guard": "<guard>" },
],
body: "{\"verdict\":\"deny\",\"reason\":...,\"guard\":...}"
}
+ dynamic_metadata {
"chio.verdict": "deny",
"chio.denial_reason": "<reason>",
"chio.denial_guard": "<guard>",
"chio.http_status": <admitted-code>
}
# Translation or kernel error -> fail closed
-> CheckResponse { status: Internal (13) }
+ DeniedHttpResponse { status: 500, body: "<generic deny>" }
+ dynamic_metadata { "chio.verdict": "deny", "chio.fail_closed": true }The Verdict type carries no receipt id, so the adapter attaches none on allow: an allow is an empty OkHttpResponse with no header mutations. Correlation data instead lands in Envoy dynamic metadata under the chio.* namespace (chio.verdict, chio.denial_reason, chio.denial_guard, chio.http_status, chio.fail_closed), which Envoy surfaces to access logs and downstream filters. A deny sets x-chio-denial-reason and x-chio-denial-guard on the response, with control characters sanitised to spaces.
This crate emits no allow-side headers, so wire them yourself
x-chio-receipt-id, x-chio-policy-hash and x-chio-verdict on an allowed response, and the harness asserts the first of them. Nothing in chio-envoy-ext-authz produces any of them: Verdict carries no receipt id (crates/protocol/chio-envoy-ext-authz/src/translate.rs:120-135) and the allow response is built with an empty OkHttpResponse(src/response.rs:30-36). Nothing else in examples/istio-ext-authz/ supplies them either, so treat all three as headers your own EnvoyKernel implementation has to set. Chio does emit a receipt-id header on its other surfaces: crates/protocol/chio-tower/src/service.rs:169 and the api-protect proxy at crates/products/chio-api-protect/src/proxy/router.rs:297.gRPC Adapter
The rest of this section is for whoever builds the adapter image. An operator wiring an existing image into a mesh, and a service author behind that mesh, need neither the trait nor the binding below: the filter configuration above and the two requests in Verify the deployment are the whole surface they touch.
The adapter implements envoy.service.auth.v3.Authorization/Check as a thin shim over a pluggable kernel trait. The production crate is chio-envoy-ext-authz. It exposes one service type and one trait:
// Shape of crates/protocol/chio-envoy-ext-authz/src/service.rs:22-52.
// Signatures are the crate's; the comments are this page's.
/// Kernel abstraction. Implementations delegate to chio-kernel, to
/// HttpAuthority in chio-http-core, or to a test stub.
#[async_trait]
pub trait EnvoyKernel: Send + Sync + 'static {
async fn evaluate(
&self,
request: ToolCallRequest,
) -> Result<Verdict, KernelError>;
}
/// Generic over the kernel implementation. Each CheckRequest is
/// translated to a ToolCallRequest, handed to K::evaluate, and the
/// returned Verdict is mapped back onto a compliant CheckResponse.
pub struct ChioExtAuthzService<K: EnvoyKernel> {
kernel: K,
}
impl<K: EnvoyKernel> ChioExtAuthzService<K> {
pub fn new(kernel: K) -> Self { Self { kernel } }
}
#[async_trait]
impl<K: EnvoyKernel> Authorization for ChioExtAuthzService<K> {
async fn check(
&self,
request: Request<CheckRequest>,
) -> Result<Response<CheckResponse>, Status> { /* ... */ }
}A minimal binding. Any type that implements EnvoyKernel plugs in; for production, write a small adapter that delegates to chio-kernel or to HttpAuthority in chio-http-core.
use async_trait::async_trait;
use chio_envoy_ext_authz::{
proto::envoy::service::auth::v3::authorization_server::AuthorizationServer,
translate::{ToolCallRequest, Verdict},
ChioExtAuthzService, EnvoyKernel, KernelError,
};
struct MyKernel;
#[async_trait]
impl EnvoyKernel for MyKernel {
async fn evaluate(
&self,
_request: ToolCallRequest,
) -> Result<Verdict, KernelError> {
// Delegate to chio-kernel / HttpAuthority / custom policy here.
Ok(Verdict::Allow)
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let svc = ChioExtAuthzService::new(MyKernel);
tonic::transport::Server::builder()
.add_service(AuthorizationServer::new(svc))
.serve("0.0.0.0:9091".parse()?)
.await?;
Ok(())
}Why the Filter Uses grpc_service
An http_service filter fails closed on every request
/chio/evaluate, /chio/verify, /chio/live, /chio/health, the approval admin routes, the capability mint and release surface, the receipt routes and /metrics, and everything else falls through a catch-all to the transparent proxy (crates/products/chio-api-protect/src/proxy/router.rs:50-111). None of them translate Envoy's HTTP-mode request conventions, and an http_service block aimed at any of them draws a response the filter reads as a deny, so every request through that listener is refused.Istio Integration
Istio is the most common Envoy-based mesh. chio plugs into Istio via AuthorizationPolicy with the CUSTOM action, which delegates the decision to an ext_authz provider.
Chio layers on top of Istio's RBAC. Istio answers "can service A talk to service B." Chio answers "does this agent have a valid capability token for this specific tool invocation, within budget, and passing all guards." Istio RBAC covers service identity (via mTLS and SPIFFE IDs) and coarse allow/deny at the path level. Chio covers per-tool capability, signed receipts, the guard pipeline (WASM, Rego, built-in), and per-capability budget limits, none of which Istio native RBAC models.
Version prerequisites
security.istio.io/v1 GA API the policies use. Deploy a dedicated ext_authz adapter image (the reference uses ghcr.io/backbay-labs/chio-ext-authz as a placeholder). Do not point the provider at ghcr.io/backbay-labs/chio-sidecar: that is the HTTP sidecar image and does not expose Envoy's gRPC Authorization/Check service.Register Chio as an ext_authz Provider
chio-ext-authz here is the Istio provider name, referenced later by provider.name. The backing Kubernetes Service is the gRPC adapter Deployment, exposed on port 9091:
apiVersion: install.istio.io/v1alpha1
kind: IstioOperator
metadata:
name: chio-ext-authz-meshconfig
namespace: istio-system
spec:
# Minimum Istio version. Earlier 1.20/1.21 builds also work for gRPC
# ext_authz but 1.22 stabilised the typed header forwarding knobs below.
meshConfig:
extensionProviders:
- name: chio-ext-authz
envoyExtAuthzGrpc:
# Fully-qualified cluster DNS name of the Service created in
# 00-chio-sidecar-deployment.yaml.
service: chio-sidecar.chio-system.svc.cluster.local
port: 9091
# Chio evaluation budget. Increase if your guard pipeline is slow.
timeout: 0.25s
# Forward identity + capability headers into the CheckRequest.
# `chio-envoy-ext-authz::translate` pulls identity from these names.
includeRequestHeadersInCheck:
- authorization
- x-chio-capability-token
- x-chio-session-id
- x-request-id
# Chio never stores or forwards raw request bodies, but small
# bodies must be available for guards that inspect content.
includeAdditionalHeadersInCheck:
x-chio-source: istio-mesh
# Status propagated back to the client on Chio failure. See
# docs/protocols/ENVOY-EXT-AUTHZ-INTEGRATION.md section 11.3.
statusOnError: "403"
# Inject the Envoy downstream peer certificate so Chio can derive a
# SPIFFE-style CallerIdentity from the mTLS handshake.
includePeerCertificate: trueTwo fields there are easy to drop and both are load-bearing. statusOnError: "403" is what the client sees when Chio itself is unreachable, so the fail-closed posture the rest of this page describes depends on it. And includeAdditionalHeadersInCheck stamps x-chio-source: istio-mesh onto the CheckRequest, which is how the adapter tells mesh traffic from anything else.
Route Traffic to Chio
Routing traffic to Chio takes three policies, not one, and the shipped manifest carries all three: a CUSTOM policy that hands matched requests to the provider, a DENY backstop for requests that carry no credential at all, and an ALLOW policy so kubelet probes keep working.
# Route mesh traffic through Chio via Istio's AuthorizationPolicy CUSTOM action.
#
# The CUSTOM action delegates the allow/deny decision to the named provider
# registered in MeshConfig (see 01-meshconfig-patch.yaml). This file contains
# three policies:
#
# 1. chio-tool-authorization -- opts workloads labelled
# `chio.world/secured=true` in the
# `agent-tools` namespace into Chio
# evaluation, with a header-driven filter
# so only capability-bearing requests pay
# the ext_authz latency.
# 2. chio-deny-unauthenticated -- fail-closed backstop: anonymous requests
# that slip past the CUSTOM match above
# are denied outright. Ensures there is
# no bypass path when the capability
# header is missing.
# 3. chio-allow-health-probes -- excludes kubelet probe traffic so the
# demo workload's `/healthz` path stays
# reachable without a capability token.
#
# All policies use `security.istio.io/v1` (GA in Istio 1.22+). The CUSTOM
# action uses the provider:name field -- the deprecated
# `.external.httpAuthorizationService` style is explicitly NOT used.
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: chio-tool-authorization
namespace: agent-tools
labels:
app.kubernetes.io/part-of: chio-protocol
spec:
# Apply only to workloads that opted in by labelling themselves.
selector:
matchLabels:
chio.world/secured: "true"
action: CUSTOM
provider:
name: chio-ext-authz
rules:
# Allow-rule #1: route POSTs to /tools/* through Chio when either an
# Chio capability token header or a Bearer Authorization header is
# present. This is the hot path for agent tool invocation.
- to:
- operation:
methods: ["POST"]
paths: ["/tools/*", "/invoke/*"]
when:
- key: request.headers[x-chio-capability-token]
notValues: [""]
# Allow-rule #2: route POSTs through Chio when the request authenticates
# with a Bearer token instead of a capability header.
- to:
- operation:
methods: ["POST"]
paths: ["/tools/*", "/invoke/*"]
when:
- key: request.headers[authorization]
values: ["Bearer *"]
# Allow-rule #3: route GETs on /tools/* (listings, schema fetches)
# through Chio only when an Authorization header is present. This
# illustrates header-based routing -- unauthenticated GETs fall
# through to the deny policy below.
- to:
- operation:
methods: ["GET"]
paths: ["/tools/*"]
when:
- key: request.headers[authorization]
values: ["Bearer *"]
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: chio-deny-unauthenticated
namespace: agent-tools
labels:
app.kubernetes.io/part-of: chio-protocol
spec:
selector:
matchLabels:
chio.world/secured: "true"
action: DENY
rules:
# Anything hitting /tools/* without either header is denied before it
# ever reaches Chio. Saves the ext_authz RTT and keeps receipts focused
# on real authenticated traffic.
- to:
- operation:
paths: ["/tools/*", "/invoke/*"]
when:
- key: request.headers[x-chio-capability-token]
values: [""]
- key: request.headers[authorization]
notValues: ["Bearer *"]
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: chio-allow-health-probes
namespace: agent-tools
labels:
app.kubernetes.io/part-of: chio-protocol
spec:
selector:
matchLabels:
chio.world/secured: "true"
action: ALLOW
rules:
- to:
- operation:
methods: ["GET"]
paths: ["/healthz", "/readyz", "/metrics"]Note what the when: conditions on the CUSTOM policy are for. They are not fine-grained authorization, which is the guard pipeline's job: they are a cost filter, so only capability-bearing or Bearer-authenticated requests pay the ext_authz round trip. Requests that match no CUSTOM rule are not thereby allowed; chio-deny-unauthenticated is the backstop that catches them, and it is the policy the credential-less curl below is meant to hit. Check that one against your own mesh before relying on it: it matches on request.headers[x-chio-capability-token] with values: [""], and Istio header conditions are evaluated against a header that is present, so a request that omits the header entirely may not match the rule.
The workload behind the mesh needs no Chio client library either way: the filter decides before the request reaches it.
Not every service needs Chio. The chio.world/secured: "true" label gates which workloads all three policies select. Services without the label fall back to standard Istio RBAC only.
Verify the Deployment
Two requests prove both ends of the contract. The first carries a capability token and must come back 200. The second carries nothing and must come back 403, refused by the DENY policy before Chio is consulted at all, which is the fail-closed invariant. The allow-side headers the README prints below are the ones the callout above says this crate does not emit, so treat them as what your own kernel implementation owes rather than as something the shipped adapter gives you.
kubectl -n agent-tools port-forward svc/demo-tool 18080:80 &
# Allow path: Chio evaluates and injects x-chio-receipt-id
curl -i -X POST \
-H "x-chio-capability-token: ${CHIO_DEMO_CAPABILITY_TOKEN}" \
--data '{"hello":"world"}' \
http://127.0.0.1:18080/tools/hello
# Deny path: the DENY AuthorizationPolicy rejects before Chio
curl -i -X POST --data '{}' http://127.0.0.1:18080/tools/helloHTTP/1.1 200 OK
x-chio-receipt-id: 01k6b1k3v...-0c7f
x-chio-policy-hash: sha256:...
x-chio-verdict: allowHTTP/1.1 403 Forbidden
x-chio-denial-guard: IstioAuthorizationThe receipt id in that first block is the README's own placeholder and it is the wrong shape. A Chio receipt id is 64 lowercase hex characters, the SHA-256 of the canonical receipt body (crates/core/chio-core-types/src/receipt/body.rs:240-243), never a ULID and never hyphenated. Expect something like 3f9a1c...8c71b0.
The same flow is scripted, with the port-forward and the assertions, in examples/istio-ext-authz/test-harness.sh. It fails loudly on any status other than 200 and 403 and on an allow response with no receipt id, and it writes both header dumps and both bodies to a timestamped artifact directory:
export CHIO_DEMO_CAPABILITY_TOKEN="$(cat ~/.chio/demo.token)"
./examples/istio-ext-authz/test-harness.shSPIFFE / SPIRE Workload Identity
In a mesh that issues SPIFFE IDs via SPIRE, the source principal in the ext_authz CheckRequest carries the SPIFFE ID. The Chio adapter lifts it into the normalized workloadIdentity record so policy and receipts bind to the workload, not the transport credential. For the full shape, the three credential kinds, and how this interacts with runtime-assurance tiers, see the Workload Identity concept. The operational recipe for gating tools on a SPIFFE ID lives in the Bind Workload Identity guide.
Consul Connect
Istio is the worked example
examples/istio-ext-authz/, walked through in Istio ext_authz. For Consul Connect the filter is identical and the HCL is yours to write.Consul Connect uses Envoy as its data plane, so the same filter applies. The configuration surface differs (HCL instead of YAML, service-defaults instead of Istio CRDs), but the semantics are the same. Consul Intentions would handle service-to-service authorization (L4 identity) while Chio handles capability-level authorization (L7 tool policy): the two layer cleanly, with Consul deciding whether the agent-orchestrator can talk to the code-execution tool and Chio deciding whether the specific capability token it presents is valid for the invocation it is about to make.
Deployment Topologies
Three topologies are possible; the right pick depends on resource budget, tenant isolation needs, and how much latency you can absorb. The shipped reference deployment runs the cluster-service model, a chio-sidecar Deployment and Service in the chio-system namespace. The latency figures below are design targets, not measured production numbers.
Sidecar (per pod)
Chio runs in the same pod as Envoy. ext_authz calls traverse loopback. Lowest latency, strongest isolation, highest resource footprint.
Cluster service
Chio runs as a centralized Deployment behind a service. Every Envoy proxy calls it over the cluster network. Simplest operationally, slightly higher latency (1-5ms in-cluster).
DaemonSet (per node)
Chio runs one instance per node. Envoy sidecars call the node-local instance. Good middle ground between the two extremes.
| Factor | Sidecar | Cluster service | DaemonSet |
|---|---|---|---|
| Latency | <1ms | 1-5ms | <1ms |
| Resource overhead | High (per pod) | Low (shared) | Medium (per node) |
| Policy isolation | Per-pod | Cluster-wide | Per-node |
| Failure blast radius | Single pod | All pods | All pods on node |
| Best for | High-security, multi-tenant | Dev/staging, low traffic | Production, single-tenant |
Latency Budget
| Component | Target | Notes |
|---|---|---|
| Envoy filter overhead | <0.1ms | In-process, negligible |
| Network to Chio | <0.5ms | Loopback or node-local |
| Chio evaluation | <2ms | Policy match plus guard pipeline |
| Receipt signing | <0.5ms | Ed25519, fast |
| Total ext_authz | <3ms | P99 target |
Optimization levers: Envoy maintains persistent gRPC connections to Chio so no per-request connection setup is paid; Chio caches compiled policy in memory and hot-swaps on reload; guard results can be cached for idempotent guards keyed on capability token plus route; receipt signing is synchronous but receipt persistence is async, so disk I/O does not block the response.
Migration Paths
From No Auth to Chio
- Deploy chio as a cluster-wide service in the
chio-systemnamespace. - Register chio as an ext_authz provider in the mesh config.
- Apply the AuthorizationPolicy CUSTOM rule to a single test workload.
- Verify receipts are produced and allow/deny behavior is correct.
- Expand via label selectors one workload at a time.
From OPA to Chio
The path for organizations already running Open Policy Agent with ext_authz is an incremental one: register Chio alongside OPA as a second provider, move policies onto Chio guards, validate parity, then retire the OPA provider. Because Istio scopes each AuthorizationPolicy to a provider by name, the two can run side by side on disjoint label selectors during the cutover.
Shadow Mode
For risk-averse rollouts the goal is to evaluate and sign a receipt for every request while still returning allow, so the shadow receipts can be analysed before enforcement is switched on. The enforcing/observe split is a deployment concern of the EnvoyKernel implementation, not a toggle the adapter itself exposes.
Next Steps
- Receipt Dashboard · visualize the receipts produced by the ext_authz adapter
- AWS Lambda · the serverless counterpart to the ext_authz sidecar model
- Budgets · per-capability spending envelopes, enforced on every request