Chio/Docs
LOGIN · JOIN

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.

rendering
Envoy's ext_authz filter calls chio for every request, receives allow/deny plus header mutations, and forwards (or rejects) accordingly.

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:

yaml
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

If Envoy cannot reach chio, the request is denied. That is the safe default for capability enforcement. Change 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:

  1. x-chio-capability-token (preferred, explicit): raw capability token id issued by chio, mapped to AuthMethod::Capability.
  2. 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.
  3. mTLS peer principal: when Envoy reports a downstream peer certificate, its SPIFFE URI or subject DN becomes the caller subject via AuthMethod::Mtls.
  4. 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

text
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

The Istio README and its test harness expect 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:

rust
// 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.

rust
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

Envoy ext_authz supports an HTTP-mode transport alongside gRPC, and Chio serves no endpoint that speaks it. The sidecar's named routes are /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

The reference manifests require Istio 1.22+ (typed-header forwarding stabilised there; 1.20 and 1.21 support gRPC ext_authz but not the header knobs used here) and Kubernetes 1.28+ for the 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:

examples/istio-ext-authz/01-meshconfig-patch.yaml34-68yaml
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: true

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

examples/istio-ext-authz/02-authorization-policy.yamlyaml
# 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.

examples/istio-ext-authz/README.md:163-172bash
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/hello
examples/istio-ext-authz/README.md:178-181text
HTTP/1.1 200 OK
x-chio-receipt-id: 01k6b1k3v...-0c7f
x-chio-policy-hash: sha256:...
x-chio-verdict: allow
examples/istio-ext-authz/README.md:187-188text
HTTP/1.1 403 Forbidden
x-chio-denial-guard: IstioAuthorization

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

examples/istio-ext-authz/README.md:141-142bash
export CHIO_DEMO_CAPABILITY_TOKEN="$(cat ~/.chio/demo.token)"
./examples/istio-ext-authz/test-harness.sh

SPIFFE / 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

The adapter is a standard Envoy ext_authz filter, so it applies to any Envoy data plane. The manifest set that ships is the Istio one, in 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.

FactorSidecarCluster serviceDaemonSet
Latency<1ms1-5ms<1ms
Resource overheadHigh (per pod)Low (shared)Medium (per node)
Policy isolationPer-podCluster-widePer-node
Failure blast radiusSingle podAll podsAll pods on node
Best forHigh-security, multi-tenantDev/staging, low trafficProduction, single-tenant

Latency Budget

ComponentTargetNotes
Envoy filter overhead<0.1msIn-process, negligible
Network to Chio<0.5msLoopback or node-local
Chio evaluation<2msPolicy match plus guard pipeline
Receipt signing<0.5msEd25519, fast
Total ext_authz<3msP99 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

  1. Deploy chio as a cluster-wide service in the chio-system namespace.
  2. Register chio as an ext_authz provider in the mesh config.
  3. Apply the AuthorizationPolicy CUSTOM rule to a single test workload.
  4. Verify receipts are produced and allow/deny behavior is correct.
  5. 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