Chio/Docs
LOGIN · JOIN

BuildConnect

Add Chio Middleware to Your HTTP Framework

Add Chio policy enforcement and signed receipts to an existing axum, Express, FastAPI, or net/http service with in-process middleware.

Prerequisites

This guide assumes you have the Chio CLI installed. If not, see the Installation guide. You also need an existing HTTP service you can modify, and working familiarity with your target framework's middleware model (tower layers for axum/tonic, Express middleware, Fastify plugins, FastAPI dependencies, or Go http.Handler wrappers).

Middleware vs Reverse Proxy

Both shapes enforce the same policy with the same kernel and produce the same signed HttpReceipt records. The choice is deployment topology. The middleware path runs the evaluator inside your service process; the proxy path runs it in a separate binary in front of your service.

rendering
Two deployment shapes over one policy engine: middleware evaluates inline and calls through to your handler, while the proxy terminates the request and forwards to an unmodified upstream. Receipts reach the same store either way.
DimensionMiddleware (this guide)Reverse proxy (chio api protect)
ProcessesOne. Evaluator inside your service.Two. Chio in a separate binary in front of the upstream.
Network hopNone in Rust; localhost sidecar elsewhere.One extra hop (client to Chio, Chio to upstream).
Code changesAdd a dependency and wire middleware.None. Upstream is unmodified.
Identity contextRich. Framework-parsed route params, auth state, session handles.Header-only. Chio sees what is on the wire.
Best forServices you own and can ship with a Chio dependency.Services you cannot modify, polyglot fleets, multi-tenant edges.

The rest of this guide walks the middleware path. If the table leans you the other way, head to Protect an API instead.


Rust: axum, warp, tonic via chio-tower

chio-tower ships a tower::Layer that wraps any inner service with chio evaluation. Because axum, tonic, and most modern Rust HTTP stacks build on tower::Service, wiring is identical across them. The crate exports:

  • ChioLayer: the tower Layer you wrap your router with. ChioService is the inner Service it produces.
  • ChioEvaluator: holds the kernel keypair, policy hash, identity extractor, route resolver, and fail-open flag. Exposed so you can evaluate directly and return an EvaluationResult (verdict, signed HttpReceipt, guard evidence) outside the middleware.
  • extract_identity / IdentityExtractor: the default header-based extractor and the function type for plugging in your own.
  • ChioTowerError: surfaced when evaluation itself fails; distinct from a Deny verdict, which is a normal 403 response.

ChioLayer::new attaches no store, so it denies

ChioLayer::new is fail-closed with no durable sink: the first mediated call denies until a store is wired through ChioLayer::builder (crates/protocol/chio-tower/src/layer.rs:27-36, with evaluator.rs:83-98). For a local scaffold use ChioLayer::new_ephemeral, which says in its name that the receipts do not survive the process. Reach for new only when you are about to hand it a store.

A minimal axum example. The layer sits in front of the router so every route inherits the evaluation:

src/main.rsrust
use chio_core_types::crypto::Keypair;
use chio_tower::ChioLayer;
use axum::{routing::get, Router, Json};
use serde_json::json;

#[tokio::main]
async fn main() {
    // Stable kernel keypair. In production this comes from a sealed seed
    // file or HSM; generate() is fine for local dev.
    let keypair = Keypair::generate();

    // policy_hash binds this process's receipts to the exact policy
    // document that was loaded. Compute it once at startup as the bare
    // lowercase sha256 hex of the policy bytes, with no "sha256:" prefix:
    // that is the form chio api protect writes and verifiers compare against.
    let policy_hash = chio_http_core::sha256_hex(std::fs::read("chio.yaml").unwrap().as_slice());

    // new_ephemeral, not new: new attaches no receipt store and denies the
    // first mediated call. Use ChioLayer::builder to wire a durable one.
    let chio = ChioLayer::new_ephemeral(keypair, policy_hash);

    let app = Router::new()
        .route("/pets", get(|| async { Json(json!({ "pets": [] })) })
            .post(|Json(b): Json<serde_json::Value>| async move { Json(json!({ "created": b })) }))
        .route("/pets/:id", get(|| async { Json(json!({ "pet": {} })) })
            .delete(|| async { Json(json!({ "deleted": true })) }))
        .layer(chio);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:4000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

What the layer does for each request:

  1. Buffers the body so its SHA-256 hash can be computed and the bytes replayed to your handler. The body type must implement http_body::Body plus From<Bytes>; axum::body::Body and Full<Bytes> qualify.
  2. Extracts caller identity via extract_identity, which checks Authorization: Bearer, X-Api-Key, Cookie, then falls through to anonymous. Raw values never leave memory; only SHA-256 hashes reach the receipt.
  3. Calls the HttpAuthority evaluator with method, path, query, caller, body hash, body length, and any capability token from X-Chio-Capability or the chio_capability query param.
  4. On Deny, returns the verdict's HTTP status (default 403), sets x-chio-receipt-id, stashes the receipt in response.extensions(), and never calls the inner handler.
  5. On Allow, forwards to the inner service and finalizes the receipt with the response status once the handler returns.

Custom identity and route resolution

The default extractor is header-only. If your service validates JWTs or looks up session cookies against a store, project that richer identity into the receipt by building an evaluator with your own extractor and passing it to ChioLayer::from_evaluator. The with_route_resolver hook on the same builder lets you collapse instance paths to OpenAPI-style templates, which is what route_pattern records in the receipt:

src/main.rsrust
use chio_core_types::crypto::Keypair;
use chio_http_core::CallerIdentity;
use chio_tower::{ChioEvaluator, ChioLayer};

fn tenant_aware_identity(headers: &http::HeaderMap) -> CallerIdentity {
    let mut caller = chio_tower::extract_identity(headers);
    if let Some(tenant) = headers.get("x-tenant-id").and_then(|v| v.to_str().ok()) {
        caller.tenant = Some(tenant.to_string());
    }
    caller
}

fn route_pattern(_method: &str, path: &str) -> String {
    // Match against your router's compiled patterns and return the template.
    if let Some(rest) = path.strip_prefix("/pets/") {
        if !rest.is_empty() && !rest.contains('/') {
            return "/pets/{id}".to_string();
        }
    }
    path.to_string()
}

fn chio_layer(policy_hash: String) -> ChioLayer {
    // new_ephemeral for a scaffold; ChioEvaluator::builder for a durable store.
    let evaluator = ChioEvaluator::new_ephemeral(Keypair::generate(), policy_hash)
        .with_identity_extractor(tenant_aware_identity)
        .with_route_resolver(route_pattern)
        .with_fail_open(false); // fail-closed is the default; make it explicit.
    ChioLayer::from_evaluator(evaluator)
}

A few constraints worth stating plainly, grounded in the current crate:

  • The body is fully buffered before evaluation. That makes body-hash binding and body-aware guards work, but it also means streaming uploads are held in memory up to the collected size. Size caps belong upstream of the layer.
  • The layer is generic over bodies that implement http_body::Body plus From<Bytes>, which covers axum::body::Body and bytes-backed HTTP bodies. tonic::body::Body does not satisfy that bound, so a live tonic gRPC service does not wrap in ChioLayer. Govern gRPC at a different seam: an interceptor that calls ChioEvaluator directly, or the sidecar.
  • Identity extractors and route resolvers are plain fn pointers, not closures. State they need must be passed through headers or compiled into the extractor at build time.

Everything Else: via the Sidecar

Outside Rust, the shape is a thin framework middleware that talks to a local Chio sidecar over HTTP. The sidecar is the same process you would run for chio api protect, but in evaluation-only mode: it exposes an internal endpoint the middleware posts normalized requests to and receives a verdict plus signed receipt. The kernel keypair stays in the sidecar; your application process never sees it.

The localhost round trip keeps the kernel signing key in a small, separately auditable binary. The sidecar loads the same OpenAPI spec and x-chio-* policy as the reverse proxy, and each evaluator writes to the same receipt store.

Sidecar client, not in-process kernel

The non-Rust paths below are sidecar clients. They add policy enforcement and receipts to your framework, but the evaluator itself runs in a neighboring process. If you need the evaluator literally inside your request lifecycle with zero external dependency, use the Rust chio-tower path above or host your service inside a tower-compatible runtime.

Add the dependency

One package per ecosystem. Python, TypeScript, and Go install from their registries; the JVM and .NET packages come in as a build coordinate and a project reference, exactly as the reference apps in the Chio source tree declare them.

EcosystemDependency line
Pythonpip install chio-asgi, or chio-django / chio-fastapi for the framework-native forms
TypeScriptnpm install @chio-protocol/express, or @chio-protocol/fastify / @chio-protocol/elysia
Goreplace github.com/backbay-labs/chio/sdks/go/chio-go-http => ../../sdks/go/chio-go-http in go.mod
JVMincludeBuild("../../sdks/jvm") in settings.gradle.kts, then implementation("world.chio:chio-spring-boot:0.1.0") in build.gradle.kts
.NET<ProjectReference Include="../../sdks/dotnet/ChioMiddleware/src/ChioMiddleware.csproj" />

The package id that csproj declares is Backbay.Chio.Middleware and the namespace is Backbay.Chio; ChioMiddleware is the project and assembly name, so it is what you reference on disk and never what you write in a using directive.

The wiring, one framework at a time

Five ecosystems ship a first-party middleware. Each needs one piece of information, the sidecar URL, and each mounts ahead of your handlers so a denied request never reaches them. Routes and policy are the sidecar's business, loaded there from --spec. All five default the sidecar URL to http://127.0.0.1:9090, but where they look first differs and the difference bites:

PackageWhere the sidecar URL comes from
@chio-protocol/*Config value, then CHIO_SIDECAR_URL, then the default (sdks/typescript/packages/node-http/src/interceptor.ts:163).
chio-go-httpSame three, in the same order (sdks/go/chio-go-http/config.go:42).
chio-spring-bootSame three (sdks/jvm/chio-spring-boot/src/main/kotlin/world/chio/ChioFilter.kt:54).
Backbay.Chio.MiddlewareSame three (sdks/dotnet/ChioMiddleware/src/ChioSidecarClient.cs:40).
chio-asgiReads no environment. ChioASGIConfig.sidecar_url is a plain field defaulting to the literal URL (sdks/python/chio-asgi/src/chio_asgi/config.py:31). Setting CHIO_SIDECAR_URL does nothing unless your own code reads it, as the reference app does.
chio-djangoReads a Django setting, not the environment. settings.CHIO_SIDECAR_URL (sdks/python/chio-django/src/chio_django/middleware.py:101-102).
sdks/python/chio-asgi/README.md:22-28sdks/typescript/packages/express/src/index.ts:12-17sdks/go/chio-go-http/README.md:29-48sdks/jvm/chio-spring-boot/README.md:47-58sdks/dotnet/ChioMiddleware/README.md:29-39
from chio_asgi import ChioASGIMiddleware, ChioASGIConfig

# Starlette / FastAPI
app.add_middleware(
    ChioASGIMiddleware,
    config=ChioASGIConfig(sidecar_url="http://127.0.0.1:9090"),
)

The chio.yaml option in those two tabs is reserved

Both quotes are the packages' own documented form, and both name a config file the middleware never reads. config is declared in sdks/typescript/packages/node-http/src/types.ts:206-210 and has no reader in that package's source; ConfigFile is set by sdks/go/chio-go-http/config.go:59-64 and read nowhere else in the module. Routes and policy come from the sidecar's own --spec. Point chio api protect at the spec and treat the option as a no-op.

The Kotlin tab has no Chio call in it, and that is the point: the Spring Boot starter auto-configures the servlet filter as soon as it is on the classpath, so a minimal application needs no extra wiring. Configure it in application.yaml under the chio prefix, or supply a ChioFilterConfig bean when you want a custom identity extractor or route resolver:

sdks/jvm/chio-spring-boot/README.md:70-77yaml
chio:
  sidecar-url: http://127.0.0.1:9090
  timeout-seconds: 5
  on-sidecar-error: deny   # or "allow" for fail-open
  enabled: true
  url-patterns:
    - "/*"
  filter-order: 1

That block is the README verbatim, and its comment on on-sidecar-error is stale. The filter's own doc comment reads Reserved no-op option. The filter always denies sidecar errors (sdks/jvm/chio-spring-boot/src/main/kotlin/world/chio/ChioFilter.kt:49). Setting it to allow changes nothing.

The other four take their options in code. The names line up across languages because they name the same five things:

What it setsPythonTypeScriptGoC#
Sidecar base URLsidecar_urlsidecarUrlWithSidecarURLSidecarUrl
Route and policy fileloaded by the sidecarconfigConfigFileloaded by the sidecar
Request timeouttimeouttimeoutMsWithTimeoutTimeoutSeconds
Custom caller extractionextractoridentityExtractorWithIdentityExtractorIdentityExtractor
Path to route patternsidecar specroutePatternResolverWithRouteResolverRouteResolver

Denied requests get a structured JSON error body in all five. Allowed requests reach your handler with the receipt id on the response, and, where the framework has a request object to hang it on, on the request too: req.chioResult in Express and request.chio_receipt in Django. chioResult is an EvaluateResponse, which is { verdict, receipt?, evidence } (sdks/typescript/packages/node-http/src/types.ts:184-188). There is no caller field on it: caller identity rides on the receipt as caller_identity_hash, and receipt is optional, so narrow before you read it.

More than one option in this table is reserved rather than live. .NET's OnSidecarError is a no-op, the TypeScript packages' onSidecarError: "allow" is asserted in their own tests to still fail closed, and chio-asgi's fail_open is documented as a legacy field kept for source compatibility (sdks/python/chio-asgi/src/chio_asgi/config.py:24-26). A sidecar error fails closed everywhere except Rust's chio-tower, whose with_fail_open(true) really does forward the request unenforced (crates/protocol/chio-tower/src/service.rs:136-142).

Per-route enforcement in FastAPI

chio-fastapi leans into FastAPI-native patterns: the chio_requires decorator declares the capability a route needs, and the package also exports the dependencies get_caller_identity and get_chio_receipt (sdks/python/chio-fastapi/src/chio_fastapi/__init__.py:3-10). There is no ASGI middleware class; enforcement is per-route through the decorator.

Two constraints come with the decorator. The handler has to take a Request: the wrapper looks for one in the call arguments and answers 500 when it finds none. And the receipt has to be read off request.state rather than injected, because the wrapper attaches it after the sidecar allows the call, which is after FastAPI has already resolved the route's dependencies (sdks/python/chio-fastapi/src/chio_fastapi/decorators.py:71-84, 171-172). get_caller_identity reads the request directly, so that one does inject.

python
from fastapi import Depends, FastAPI, Request
from chio_fastapi import chio_approval, chio_requires, get_caller_identity

app = FastAPI()

@app.get("/pets/{pet_id}")
@chio_requires("pets-api", "get_pet", operations=["Invoke"])
async def get_pet(
    pet_id: str,
    request: Request,
    caller = Depends(get_caller_identity),
):
    # Handlers under @chio_requires must be async def: the decorator wraps
    # them and awaits the inner handler. caller.subject is the SHA-256 hex
    # digest of the bearer token, API key, or session cookie value, with no
    # method prefix, or the literal "anonymous"; caller.auth_method records
    # which signal produced it. The verified receipt is on request.state.
    receipt = request.state.chio_receipt
    return {"pet_id": pet_id, "caller": caller.subject, "receipt_id": receipt.id}

# Stack @chio_approval on top of @chio_requires to gate a write on an
# operator-issued approval token.
@app.delete("/pets/{pet_id}")
@chio_approval(threshold_cents=0)
@chio_requires("pets-api", "delete_pet", operations=["Invoke"])
async def delete_pet(pet_id: str, request: Request):
    # chio_approval looks for an X-Chio-Approval header and answers 403
    # CHIO_APPROVAL_REQUIRED when it is absent. It does that on every request:
    # threshold_cents and currency shape the error message and the metadata
    # left on request.state.chio_approval, and are not compared against a cost.
    return {"deleted": pet_id}

Go: one wrapper covers every router

chio.Protect takes an http.Handler and returns one, so it composes with anything that speaks the standard interface. Gin, Echo, chi, and gorilla/mux all expose an http.Handler adapter, and the same wrapper covers all four. The reference app wraps a chi router exactly this way:

examples/hello-chi/main.go:34-49go
func protectedHandler(sidecarURL string) http.Handler {
	return chio.Protect(
		newRouter(),
		chio.WithSidecarURL(sidecarURL),
	)
}

func newRouter() http.Handler {
	router := chi.NewRouter()

	router.Get("/healthz", healthz)
	router.Get("/hello", hello)
	router.Post("/echo", echo)

	return router
}

On an allowed request the wrapper sets X-Chio-Receipt-Id on the response, so downstream logging correlates the call to its receipt without parsing a body:

GET /hellotext
HTTP/1.1 200 OK
Content-Type: application/json
X-Chio-Receipt-Id: bfc45d50f4c8c105c6720ad2c6a466f205c1382421e7e9bbd21f8b8cb5be9e92
Content-Length: 29

{"message":"hello from chi"}

Keep the kernel out-of-process

The sidecar split is not incidental. Keeping the kernel keypair, policy evaluator, and receipt signer in a small separate binary shrinks the trusted computing base and makes chio's behavior independent of your app's dependency graph. Do not try to inline the kernel into your application runtime just to avoid the localhost hop; the trust story gets worse the moment application code can reach the signing key.

What Gets Signed

Every evaluated request produces an HttpReceipt (from chio-http-core). On allowed requests you get two: a decision receipt signed before the handler runs and a final receipt once the response status is known, linked by metadata.chio_decision_receipt_id. Denied requests produce a single final-scope receipt because there is no upstream call to wait on.

A real one, read out of a sidecar's store after an allowed write. Its route_selection metadata block is dropped here for length; nothing else is edited.

HttpReceipt, allowed writetranscript
$ python3 -c 'import json, sqlite3, sys
$ rows = sqlite3.connect("receipts.sqlite").execute(
$     "SELECT receipt_json FROM http_receipts").fetchall()
$ allow = [json.loads(r[0]) for r in rows
$          if json.loads(r[0])["verdict"]["verdict"] == "allow"
$          and json.loads(r[0])["method"] == "POST"][0]
$ allow["metadata"].pop("route_selection", None)
$ json.dump(allow, sys.stdout, indent=2)'
{
  "id": "a8fcfc7de60a1d06f66f6b9ab7fd3b788d94cd90ec1ff5b7e5239e126c355859",
  "request_id": "01a0703f-620f-7de2-8d28-8d98a738da95",
  "route_pattern": "/orders/{orderId}/refund",
  "method": "POST",
  "caller_identity_hash": "5a45e80d1ab1a73e8c2644f3f6c6e53974d0a986c2fc86692372d3f130248c9c",
  "verdict": {
    "verdict": "allow"
  },
  "receipt_kind": "mediated_decision",
  "boundary_class": "prevent",
  "tool_origin": "caller_executed",
  "redaction_mode": "none",
  "evidence": [
    {
      "guard_name": "CapabilityGuard",
      "verdict": true,
      "details": "valid capability token presented"
    }
  ],
  "response_status": 200,
  "timestamp": 1788589597,
  "content_hash": "37458db593fb679bf8629b8f345d39c9f32c264e9b045340dc4d11248ccf8433",
  "policy_hash": "215a1eecb13caf8340c4905407b0c03f2a6309866ff2ff0a5a5f9735ea3b3566",
  "trust_level": "mediated",
  "capability_id": "sidecar-f80ee84531c5d4ad1b7f068f3187c2fcf7eb4f0d0ac75a5e83ba01397b9525f0",
  "metadata": {
    "chio_decision_receipt_id": "6285a7e5c796aeb7fb872c5b3f439fbe6ac3d4fd94edd05c7f8399020b036270",
    "chio_http_status_scope": "final",
    "chio_kernel_receipt_id": "1b409feb6b244d7758d988fc4d6c2fcb2df201782ac725be67b2cfc97806a03c"
  },
  "kernel_key": "04c8e7aadf96a0af7d148b8b25e0cc0e2509bbdce3e1266661168c262c822b7e",
  "signature": "ba06c6b3928002bd611408fa0e8475a73c07a75e363ec5e72d617e47408032e24acea7a8f5484dbd0723ff002e0f8c3ac2690fedeaa4308e154d82bbedf3ee05"
}
exit 0allow
  • id and every hash on the record are bare lowercase hex, 64 characters, with no ed25519: or sha256: prefix. The id is the SHA-256 of the canonical body with id removed, so it is content-addressed rather than allocated. The request_id is a dashed UUIDv7, which is the one identifier on the record that is not a hash.
  • session_id is absent, not null. The field carries skip_serializing_if = "Option::is_none" (crates/platform/chio-http-core/src/receipt.rs:42-44), so a receipt with no session omits the key entirely.
  • receipt_kind, boundary_class, tool_origin, redaction_mode and trust_level are not optional. Any receipt shape missing one of them is not an HttpReceipt.
  • route_pattern is the template, not the instance URL. In chio-tower, supply this via with_route_resolver; in the sidecar path, the pattern comes from the loaded OpenAPI spec, and an unmatched path falls back to the literal path.
  • caller_identity_hash is a SHA-256 over the canonical JSON of the caller identity record, not over the raw credential. Bearer tokens and API keys are never stored raw.
  • content_hash covers the canonicalized method, route pattern, path, query, and body hash (crates/platform/chio-http-core/src/request.rs:168-188). Two requests that differ only in body bytes produce different hashes, so the receipt is bound to the exact request that was evaluated.
  • policy_hash fingerprints the policy document that was in effect. Rotating policy changes the hash, which downstream verifiers can detect.

The full schema, including the canonical JSON layout used for signature verification, lives in Receipt Format.


Policy Patterns

Middleware and the proxy derive route and method policy from the OpenAPI spec plus x-chio-* extensions, as described in the Protect an API guide. Policy does not come from a chio.yaml route block. A few patterns recur:

Route and method allowlist

Only operations present in the spec become tools; anything not enumerated is unknown and denies side effects by default. Narrow a route with x-chio-* so a purely-read POST stays session-scoped and a sensitive route is guarded:

openapi.yamlyaml
paths:
  /pets:
    get:
      operationId: listPets
      responses:
        "200": { description: OK }
    post:
      operationId: createPet
      x-chio-side-effects: true      # requires a capability token
      responses:
        "201": { description: Created }
  /pets/{id}:
    delete:
      operationId: deletePet
      x-chio-approval-required: true # deny-by-default, operator approval
      responses:
        "204": { description: Deleted }

Body-size caps

The evaluator receives the request body length, so oversized uploads can be rejected before the handler runs. This is a code-level bound, not a spec field: in Rust set it with ChioService::with_max_body_bytes; the non-Rust sidecar exposes an equivalent server-side max_body_bytes ceiling.

Egress per route

When a route itself makes outbound calls (webhook dispatcher, third-party integration), scope the capability's egress grant to a narrow URL pattern. Any unapproved destination denies at the egress guard and leaves a receipt.

Full authoring reference: Write a Policy.

Body-aware guards require buffering

Guards that inspect request bodies (schema gating, secret scanning, content-type enforcement) require the full body in memory before the verdict lands. The Rust layer handles this by collecting the body before calling the inner service; the sidecar middlewares do the same via their framework hooks. Know where that buffer boundary sits in your stack and set a body-size ceiling (ChioService::with_max_body_bytes in Rust, or the sidecar's max_body_bytes) so a single oversized upload cannot eat your process memory.

When to Use Middleware vs the Sidecar Proxy

SituationMiddlewarechio api protect
You own the service and ship its binary.Yes.Optional.
Service is a closed-source third party.Not available.Yes.
Polyglot fleet, one governance surface.Per-language wiring.Preferred. One binary per service.
Receipts should reflect post-auth identity.Strong. Sees framework auth state.Header-only unless your auth is header-based.
Need governance without a deploy.Requires a deploy.Rollable in front of a running service.
Already behind Envoy or a service mesh.Works, but overlaps the mesh.Or use Envoy ext_authz.

A common production shape is both: middleware in services you own,chio api protect in front of the ones you do not, and a single receipt store collecting evidence from both surfaces.


Verify the Result

The middleware is wired correctly when three things are true at once, and all three are visible from outside the process.

The sidecar is reachable and durable. Ask it before you ask your app:

api-protect · healthtranscript
$ curl -s http://127.0.0.1:9090/chio/health
{"status":"healthy","version":"0.1.0","receipt_backend":"durable","revocation_backend":"durable"}
exit 0

receipt_backend reading ephemeral rather than durable is the single most common reason a middleware that looks wired up produces receipts nobody can find afterwards.

An allowed request carries a receipt id back. Every middleware sets a receipt header on the response. The Python packages name it X-Chio-Receipt by default and the others X-Chio-Receipt-Id. A 200 with no receipt header means the request bypassed the middleware, usually through an exclude list:

hello-fastapi · GET /hellotranscript
$ cat hello.headers hello.json
HTTP/1.1 200 OK
date: Sat, 05 Sep 2026 11:49:07 GMT
server: uvicorn
content-length: 32
content-type: application/json
x-chio-receipt: 1dba0f38326d8eaba47d2b8e50a54fdeb0da056639cbaa20fca0100e0dc67f64

{"message":"hello from fastapi"}
exit 0

A refused request never reaches your handler. The refusal is the important half of the check, because a middleware that only ever allows is indistinguishable from one that is not mounted. Send a side-effect request with no capability and read the status:

hello-fastapi · POST /echo, no capabilitytranscript
$ cat deny.headers deny.json
HTTP/1.1 403 Forbidden
date: Sat, 05 Sep 2026 11:49:08 GMT
server: uvicorn
content-type: application/json
content-length: 103
x-chio-receipt: 5d31d1463fe3819b3a5ffae0fb89e01c92339c3e4da455e6701511509690aaa3

{"error": "CapabilityGuard", "message": "side-effect route requires a capability token", "status": 403}
exit 0

The denial is receipted too, so both lanes end up in the same store and an audit can count them.


Failures and Recovery

SymptomCause and fix
Every request denies, including safe reads, in a Rust serviceThe layer was built with ChioLayer::new, which attaches no receipt store and fails the first mediated call closed. Use new_ephemeral for a scaffold or builder for a real store.
Every request denies, in a sidecar-backed serviceThe sidecar is unreachable and the middleware is fail-closed. Check /chio/live on the URL the middleware resolved, remembering that chio-asgi ignores CHIO_SIDECAR_URL and chio-django reads a Django setting.
Setting a fail-open option changes nothingExpected. Only chio-tower's with_fail_open(true) is live; the rest are reserved no-ops.
Receipts exist but chio receipt list returns nothing for themYou are querying the wrong table. HttpReceipt rows live in http_receipts; the CLI lists the kernel receipts, whose tool server is chio_http_authority. See Reading receipts back.
A large upload hangs or the process growsThe body is fully buffered before evaluation so it can be hashed and replayed. Cap request size upstream of the middleware rather than inside it.
A tonic gRPC service will not compile with the layertonic::body::Body does not satisfy the From<Bytes> bound the replay path needs. Call ChioEvaluator from an interceptor instead.
A route is governed that should not be, or the reverseRoute policy comes from the sidecar's spec, not from a middleware config file. Fix --spec, or the framework's own exclude list for paths like /healthz that should sit outside Chio entirely.

Next Steps

  • Architecture · how the kernel, guard pipeline, and receipt store fit together regardless of the request entry point.
  • Protect an API · the reverse-proxy counterpart to this guide, for services you cannot modify.
  • Write a Policy · HushSpec authoring for capability scopes, approval rules, and guard configuration.
  • Envoy ext_authz · run chio as an external authorization service at the mesh layer when middleware is too coupled and a sidecar is too coarse.
  • Trust Control Plane · swap local policy, dev keys, and SQLite for hosted equivalents without touching application code.