Envoy ext_authz
Put the Chio authority behind Envoy's external authorization filter so each matched request is admitted before Envoy forwards it.
On this page
With Chio as Envoy's external authorization backend, the kernel admits or refuses each request a listener matches before Envoy forwards it to a service. The ext_authz filter sends a check request to the Chio authority over gRPC; the authority checks the caller's capability and guards, records its decision, and answers allow or deny.
One authority covers every workload behind the proxy, in any language or framework. Istio, Consul Connect, Gloo, standalone Envoy, and Cilium's L7 policy layer all use the same filter, and the proxy stays the workload's only sidecar. Only traffic the filter configuration matches enters the governed path.
StartEnvoy holds each matched request until the authority answers
The filter intercepts every request, or a configured subset, and sends a check request carrying the method, path, headers, and optionally the body. The authority returns allow or deny, with optional header mutations for the upstream request or the client response.
κcapabilityσscopegguardsBbudget
Envoy forwards only after the authority allows
The ext_authz filter holds the matched request and sends a CheckRequest with the method, path, headers, and optionally the body over gRPC. The authority checks the capability and guards and records its decision; Verdict::Allow maps to CheckResponse status OK with no header mutations, and Envoy forwards the request upstream. Verdict to CheckResponse.
chio-envoy-ext-authz serves the gRPC transport (HTTP/2, binary protobuf) through the generated envoy.service.auth.v3.Authorization service, so the filter uses grpc_service. The receipt records permission to forward; correlate its request id with the upstream's operation to see the effect.
1. Run the authority, Envoy, and a protected resource
Download the Rust authority project. It includes the gRPC binding, durable stores, an Envoy configuration, and a small document API whose files show the effect of each request. Install Envoy 1.39.1, Python 3, jq, and Rust 1.94.1, then start each process from the extracted directory in its own terminal.
# Terminal 1: the Rust authority and its five-minute local grant.
cargo run --locked
# Terminal 2: the protected document resource.
python3 upstream.py
# Terminal 3: Envoy.
envoy -c envoy.yaml --concurrency 2 --disable-hot-restartAll three listeners bind loopback; a deployment puts the upstream behind the governed proxy boundary.
2. Send one refused and one admitted request
Submit a document through Envoy without a grant, then with the project's local grant, then list the documents.
curl -i http://127.0.0.1:8080/documents \
-H 'Content-Type: application/json' -d '{"text":"Review this document"}'
curl --fail-with-body -i http://127.0.0.1:8080/documents \
-H "X-Chio-Capability-Token: $(jq -c . state/capability.json)" \
-H 'Content-Type: application/json' -d '{"text":"Review this document"}'
curl --fail-with-body http://127.0.0.1:8080/documentsYou should see the first request refused before it reaches the document API, and the second save the document. Inspect upstream-state/documents.ndjson separately from the proxy response, and read the authorization records under state/http-receipts. The project README adds the automated Envoy acceptance command and an authority-outage exercise.
3. Pass the caller's capability to the authority
The adapter projects the check request onto Chio's request model. Method and path map directly, Envoy's request id becomes request_id, and the body is hashed to SHA-256 and never stored in full. Caller identity comes from the first of these sources present:
x-chio-capability-token. The preferred, explicit header: a capability token id issued by Chio, mapped toAuthMethod::Capability.Authorization: Bearer <token>. Mapped toAuthMethod::Bearer; the token is reduced to a SHA-256 digest at once, and 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 through
AuthMethod::Mtls. - None. The request is evaluated under
AuthMethod::Anonymous, and your policy decides whether anonymous callers may proceed.
The adapter removes both credential headers from the policy-header map and passes the capability in typed identity fields. Envoy's original upstream request still carries them, because the default allow response makes no header mutations. Strip them on the protected route when the upstream does not need them:
virtual_hosts:
- name: protected-tools
domains: ["*"]
request_headers_to_remove: [authorization, x-chio-capability-token]
routes:
- match: {prefix: "/"}
route: {cluster: tools}Header removal belongs to the route configuration, after authorization; see Envoy's virtual-host header contract. Keep full-body buffering on when the authority binds the payload, since a digest of a partial body cannot bind bytes the upstream receives later.
4. Point the filter at the authority
A minimal gRPC-mode filter chain and the HTTP/2 cluster that reaches the authority:
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: false
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 }With failure_mode_allow: false, Envoy refuses a request when it cannot reach the authority. Set it to true only for workloads where availability outweighs enforcement. Keep the filter on grpc_service: an http_service block draws a response the filter reads as a deny, so every request through that listener is refused.
5. Register the authority in Istio
Istio delegates matched requests through an AuthorizationPolicy CUSTOM action, and its service identity and native policies still apply. Start from an installed sidecar mesh and your own built, digest-pinned authority image that serves envoy.service.auth.v3.Authorization/Check on port 9091 with durable stores. The ordinary Chio HTTP sidecar serves a different API; configure and authenticate a mesh listener before you deploy, since the local project binds loopback.
meshConfig:
extensionProviders:
- name: chio-ext-authz
envoyExtAuthzGrpc:
service: chio-ext-authz.chio-system.svc.cluster.local
port: 9091
timeout: 0.25s
failOpen: false
statusOnError: "403"
includeRequestBodyInCheck:
maxRequestBytes: 65536
allowPartialMessage: false
packAsBytes: trueMerge the excerpt into your installation values and keep the mesh's other extension providers. With failOpen: false, an unavailable authority refuses protected traffic, and full-body buffering keeps a partial payload digest from authorizing a different complete payload. Check the generated proxy configuration against Istio's gRPC provider contract. Then apply both policies to workloads labeled chio.world/secured: "true" in agent-tools:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: chio-tool-authorization
namespace: agent-tools
spec:
selector:
matchLabels:
chio.world/secured: "true"
action: CUSTOM
provider:
name: chio-ext-authz
rules:
- to:
- operation:
paths: ["/tools/*", "/invoke/*"]
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: chio-allow-tools-and-probes
namespace: agent-tools
spec:
selector:
matchLabels:
chio.world/secured: "true"
action: ALLOW
rules:
- to:
- operation:
paths: ["/tools/*", "/invoke/*"]
- to:
- operation:
methods: ["GET"]
paths: ["/healthz", "/readyz", "/metrics"]The CUSTOM rule sends protected paths to the authority whether or not credentials are present. The ALLOW policy admits those paths after CUSTOM succeeds and lets the listed GET probes through without calling Chio; other paths are refused.
Istio evaluates CUSTOM, then DENY, then ALLOW, so an ALLOW rule cannot override a CUSTOM refusal, though an extra ALLOW in an existing namespace or mesh policy can admit a path this example leaves unmatched. See Istio's authorization evaluation rules. Workloads without the label keep their existing behavior.
6. Verify the mesh refuses a call without a grant
Use Larkfield's pricing service, with a POST endpoint at /tools/quote, and set BASALT_CAPABILITY_TOKEN to Basalt's grant covering it. Port-forward the tool Service in one terminal, then send both requests from another.
kubectl -n agent-tools port-forward svc/pricing 18080:80curl --fail-with-body -i -X POST \
-H "x-chio-capability-token: ${BASALT_CAPABILITY_TOKEN}" \
-H 'Content-Type: application/json' --data '{"sku":"sku-204","percent":15}' \
http://127.0.0.1:18080/tools/quote
curl -i -X POST -H 'Content-Type: application/json' --data '{"sku":"sku-204","percent":15}' \
http://127.0.0.1:18080/tools/quoteYou should see the granted call reach the tool and return its success status, and the credential-free call return 403 with no quote recorded. Confirm both decisions in the authority's retained receipts, never from a success status alone. Stop the authority and repeat the protected call to check failure behavior, inspect the tool's state and Envoy's access log to tell admission from an applied effect, and stop the port-forward.
Three placements trade isolation for shared operation
Each placement needs a gRPC authority with its own listener, authentication, and durable state. The latency figures are design targets; measure the deployed path.
| Placement | Latency target | Overhead | Policy isolation | Failure reach | Fits |
|---|---|---|---|---|---|
| Sidecar, per pod | <1 ms | High, per pod | Per pod | One pod | High-security, multi-tenant |
| Cluster service | 1 to 5 ms | Low, shared | Explicit tenant partitioning | All pods | Central authority operations |
| DaemonSet, per node | <1 ms | Medium, per node | Per node | All pods on the node | Node-local sharing |
A sidecar calls over loopback and gives each workload separate processes and state. A cluster service is the simplest to operate, with network and shared-queue latency to measure and tenant boundaries to configure. A DaemonSet runs one instance per node that the pods on that node share.
ext_authz protocol and operations reference
Search for CheckResponse, chio.verdict, EnvoyKernel, SPIFFE, or latency.
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 an allow is an empty OkHttpResponse with no header mutations and no policy hash. Correlation data lands in Envoy dynamic metadata under chio.* (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, with control characters replaced by spaces. Keep the authority's retained receipt and correlate its request id with Envoy's access log; a deployment that exposes a receipt header adds that response mapping itself.
Authority binding
For whoever builds the authority image: the adapter implements envoy.service.auth.v3.Authorization/Check as a thin shim over a pluggable kernel trait in chio-envoy-ext-authz, which 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> { /* ... */ }
}The downloadable binding delegates to Chio's HTTP authority, which checks a presented signed capability and retains the decision. Blocking evaluation and receipt writes have an explicit concurrency limit, and an internal error, overload, or unfinished decision refuses the request. The project carries a source correction to the adapter's protobuf schema, checked against Envoy 1.39.1 and recorded in its PATCHES.md; an independent wire test checks the field numbers and address message boundary, and its acceptance runner sends HTTP traffic through Envoy.
#[async_trait::async_trait]
impl EnvoyKernel for Authority {
async fn evaluate(&self, request: ToolCallRequest) -> Result<Verdict, KernelError> {
let authority = self.authority.clone();
let directory = self.directory.clone();
self.blocking
.run(move || {
anyhow::ensure!(
request.path.len() <= 4096
&& request.query.len() <= 4096
&& request.body_length <= 65_536,
"request exceeds this authority's limits"
);
let method: HttpMethod = serde_json::from_value(json!(request.method))?;
let query: HashMap<String, String> =
url::form_urlencoded::parse(request.query.as_bytes())
.into_owned()
.collect();
// The translated token is presented to HttpAuthority for signature,
// expiry and grant checks. An opaque ID alone is insufficient.
let evaluation = authority.evaluate(HttpAuthorityInput {
request_id: request.request_id,
method,
route_pattern: request.path.clone(),
path: &request.path,
query: &query,
caller: CallerIdentity::anonymous(),
body_hash: request.body_hash,
body_length: request.body_length,
session_id: request.session_id,
capability_id_hint: None,
presented_capability: request.capability_id.as_deref(),
requested_tool_server: None,
requested_tool_name: None,
requested_arguments: None,
model_metadata: None,
unsupported_authorization_extension: None,
execution_nonce: None,
policy: if method.is_safe() {
HttpAuthorityPolicy::SessionAllow
} else {
HttpAuthorityPolicy::DenyByDefault
},
})?;
// An authorization receipt records admission. It does not assert
// that Envoy subsequently dispatched or that the upstream committed.
let receipt = &evaluation.receipt;
anyhow::ensure!(receipt.verify_signature()?, "invalid authority receipt");
files::create(
&directory
.join("http-receipts")
.join(format!("{}.json", receipt.id)),
&serde_json::to_vec_pretty(receipt)?,
)?;
Ok(match evaluation.verdict {
chio_http_core::Verdict::Allow => Verdict::Allow,
chio_http_core::Verdict::Deny {
reason,
guard,
http_status,
..
} => Verdict::Deny {
reason,
guard,
http_status,
},
other => Verdict::deny(
format!("Authorization did not finish: {other:?}"),
"http-authority",
),
})
})
.await
.map_err(|error| {
eprintln!("Authority evaluation failed: {error:#}");
KernelError::evaluation(error)
})
}
}Listener, message bounds, and shutdown
use chio_envoy_authority_example::Authority;
use chio_envoy_ext_authz::{
proto::envoy::service::auth::v3::authorization_server::AuthorizationServer, ChioExtAuthzService,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let directory = std::env::var("CHIO_AUTHORITY_STATE").unwrap_or_else(|_| "state".into());
let authority = Authority::open(std::path::Path::new(&directory))?;
authority.issue_local_grant()?;
let address = std::env::var("CHIO_AUTHORITY_LISTEN")
.unwrap_or_else(|_| "127.0.0.1:9091".into())
.parse::<std::net::SocketAddr>()?;
anyhow::ensure!(
address.ip().is_loopback(),
"this local example requires a loopback listener"
);
let signal = chio_example_runtime::shutdown_signal()?;
println!("Envoy authority: {address}; five-minute grant: {directory}/capability.json");
let served = tonic::transport::Server::builder()
.concurrency_limit_per_connection(8)
.timeout(std::time::Duration::from_secs(2))
.add_service(
AuthorizationServer::new(ChioExtAuthzService::new(authority.clone()))
.max_decoding_message_size(131_072),
)
.serve_with_shutdown(address, signal)
.await;
let drained = authority.shutdown().await;
served?;
drained?;
Ok(())
}HTTP-mode transport
Chio serves no endpoint for ext_authz's HTTP mode. The sidecar's named routes are /chio/evaluate, /chio/verify, /chio/live, /chio/health, the approval admin routes, the capability mint and release routes, the receipt routes, and /metrics; 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 translates Envoy's HTTP-mode request conventions.
SPIFFE and SPIRE workload identity
In a mesh that issues SPIFFE IDs through SPIRE, the check request's source principal carries the SPIFFE ID, and the adapter uses it as the caller subject when no capability or bearer credential takes precedence. The authority must trust the proxy that supplies this identity and bind it to its policy and receipts. Workload identity covers the three credential kinds and runtime-assurance tiers; Bind workload identity gates tools on a SPIFFE ID.
Consul Connect
Consul Connect uses Envoy as its data plane and configures the filter through envoy_extensions.ext_authz, with HCL and service-defaults in place of Istio CRDs. Consul Intentions decide service-to-service authorization at L4, for example whether an orchestrator may reach a code-execution tool; Chio decides at L7 whether the capability token presented is valid for the invocation. The shipped manifests are the Istio set in examples/istio-ext-authz/, walked through in Istio ext_authz; the Consul HCL is yours to write.
Latency budget
| Component | Target | Notes |
|---|---|---|
Envoy filter overhead | <0.1 ms | In process |
Network to Chio | <0.5 ms | Loopback or node-local |
Chio evaluation | <2 ms | Policy match plus guard pipeline |
Receipt signing | <0.5 ms | Ed25519 |
Total ext_authz | <3 ms | P99 target |
Measure the deployed path with its guard pipeline and durable stores. Persistent gRPC connections avoid repeated setup, while body buffering, admission queues, signing, and receipt persistence add latency. The supplied authority bounds blocking evaluation and store writes before it returns its decision, so size the proxy timeout for synchronous persistence and uncached authorization.
Migration paths
- Deploy Chio as a cluster-wide service in the
chio-systemnamespace. - Register it as an ext_authz provider in the mesh configuration.
- Apply the CUSTOM rule to one test workload.
- Check that receipts are produced and that allow and deny behave as expected.
- Expand one workload at a time through label selectors.
From Open Policy Agent, register Chio beside OPA as a second provider, move policies onto Chio guards, check parity, then retire the OPA provider; Istio scopes each AuthorizationPolicy to a provider by name, so both run on disjoint label selectors during the cutover. For a shadow rollout, an EnvoyKernel implementation evaluates and signs a receipt for every request while returning allow, so the receipts can be analyzed before enforcement; the split between enforcing and observing lives in that implementation.