Chio/Docs
LOGIN · JOIN

Envoy ext_authz

Put the Chio authority behind Envoy's external authorization filter so each matched request is admitted before Envoy forwards it.

Guide chio@5f63e6e

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.

Start

Envoy 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.

Envoy ext_authzChoose an outcome.

κ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.

One matched request held by Envoy’s ext_authz filter while the Chio authority decides, then forwarded to the upstream service. Admission and the upstream effect are separate events; header mutations, body buffering, and mesh policy are left out.Istio ext_authz

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.

Terminals 1 to 3bash
# 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-restart

All 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.

Terminal 4bash
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/documents

You 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:

  1. x-chio-capability-token. The preferred, explicit header: a capability token id issued by Chio, mapped to AuthMethod::Capability.
  2. Authorization: Bearer <token>. Mapped to AuthMethod::Bearer; the token is reduced to a SHA-256 digest at once, and the raw value never leaves the translation layer.
  3. mTLS peer principal. When Envoy reports a downstream peer certificate, its SPIFFE URI or subject DN becomes the caller subject through AuthMethod::Mtls.
  4. 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:

envoy.yaml · virtual hostyaml
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:

envoy.yaml · ext_authz filteryaml
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.

Istio installation values · meshConfigyaml
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: true

Merge 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:

agent-tools/authorization.yamlyaml
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.

Terminal 1 · pricing Servicebash
kubectl -n agent-tools port-forward svc/pricing 18080:80
Terminal 2 · granted and credential-free requestsbash
curl --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/quote

You 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.

PlacementLatency targetOverheadPolicy isolationFailure reachFits
Sidecar, per pod<1 msHigh, per podPer podOne podHigh-security, multi-tenant
Cluster service1 to 5 msLow, sharedExplicit tenant partitioningAll podsCentral authority operations
DaemonSet, per node<1 msMedium, per nodePer nodeAll pods on the nodeNode-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

Verdict to CheckResponse

Verdict mappingtext
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.

service.rs · shaperust
// 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.

envoy-authority/src/lib.rsL103–181rust
#[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)
            })
    }
}

Open the complete source file

Listener, message bounds, and shutdown
envoy-authority/src/main.rsL1–33rust
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(())
}

Open the complete source file

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

ComponentTargetNotes
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

  1. Deploy Chio as a cluster-wide service in the chio-system namespace.
  2. Register it as an ext_authz provider in the mesh configuration.
  3. Apply the CUSTOM rule to one test workload.
  4. Check that receipts are produced and that allow and deny behave as expected.
  5. 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.

Next: inspect the authority's receipts