Add Chio Middleware to Your HTTP Framework
You have a running axum, Express, FastAPI, or net/http service and you want Chio policy enforcement and signed receipts on every request without standing up a reverse proxy in front of it. This guide is the in-process middleware flow: you keep the single-process deployment shape, add one dependency, and wire a layer or plugin into your existing stack. The sibling guide Protect an API covers the zero-code reverse-proxy path (chio api protect); pick middleware when you own the service and want tighter coupling, pick the proxy when you do not own the code or want a language-agnostic sidecar.
Prerequisites
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.
| Dimension | Middleware (this guide) | Reverse proxy (chio api protect) |
|---|---|---|
| Processes | One. Evaluator inside your service. | Two. Chio in a separate binary in front of the upstream. |
| Network hop | None in Rust; localhost sidecar elsewhere. | One extra hop (client to chio, chio to upstream). |
| Code changes | Add a dependency and wire middleware. | None. Upstream is unmodified. |
| Identity context | Rich. Framework-parsed route params, auth state, session handles. | Header-only. Chio sees what is on the wire. |
| Best for | Services you own and ship with chio as a first-class 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 towerLayeryou wrap your router with.ChioServiceis the innerServiceit produces.ChioEvaluator— holds the kernel keypair, policy hash, identity extractor, route resolver, and fail-open flag. Exposed so you can evaluate directly and return anEvaluationResult(verdict, signedHttpReceipt, 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 aDenyverdict, which is a normal 403 response.
A minimal axum example. The layer sits in front of the router so every route inherits the evaluation:
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 once at startup from chio.yaml or
// the OpenAPI spec and pass it here.
let chio = ChioLayer::new(keypair, "sha256:c7e9...02af".to_string());
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:
- 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::BodyplusFrom<Bytes>;axum::body::BodyandFull<Bytes>qualify. - Extracts caller identity via
extract_identity, which checksAuthorization: Bearer,X-Api-Key,Cookie, then falls through to anonymous. Raw values never leave memory; only SHA-256 hashes reach the receipt. - Calls the
HttpAuthorityevaluator with method, path, query, caller, body hash, body length, and any capability token fromX-Chio-Capabilityor thechio_capabilityquery param. - On
Deny, returns the verdict's HTTP status (default 403), setsx-chio-receipt-id, stashes the receipt inresponse.extensions(), and never calls the inner handler. - 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:
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() -> ChioLayer {
let evaluator = ChioEvaluator::new(Keypair::generate(), "sha256:c7e9...".into())
.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
chio-towercrate works with replayable tower body types includingaxum::body::Bodyand bytes-backed HTTP bodies. Realtonic::body::Bodyreplay is listed in the crate docs as a follow-on concern; gRPC works today via the generic tower/HTTP2 path, but treat tonic coverage as provisional rather than fully qualified. - Identity extractors and route resolvers are plain
fnpointers, not closures. State they need must be passed through headers or compiled into the extractor at build time.
Node, Python, Go: via the Sidecar
Outside Rust, the recommended shape today 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.
Why the split, despite the extra localhost round-trip: the kernel signing key stays in a narrow auditable binary (small TCB), the sidecar loads the same chio.yaml and OpenAPI spec as reverse-proxy mode (policy parity), and every evaluator writes to one receipt store so audit queries do not care which surface produced the record.
Sidecar client, not in-process kernel
chio-tower path above or host your service inside a tower-compatible runtime.Express (Node / TypeScript)
The @chio-protocol/express package exports a chio() middleware and a matching error handler. Both read the same chio.yaml config as the CLI. Mount chio() before any governed handlers; denied requests never reach them, allowed ones arrive with req.chioResult populated (verdict, caller identity, signed receipt).
import express from "express";
import { chio, chioErrorHandler } from "@chio-protocol/express";
const app = express();
app.use(
chio({
config: "./chio.yaml",
sidecar: { endpoint: "http://127.0.0.1:9091" },
})
);
app.get("/pets", (_req, res) => res.json({ pets: [] }));
app.post("/pets", (req, res) => res.status(201).json({ created: req.body }));
// Turns evaluator errors (not Deny verdicts, which are already 403s)
// into structured ChioErrorResponse bodies.
app.use(chioErrorHandler);
app.listen(4000);FastAPI (Python)
FastAPI has two idiomatic hooks: add_middleware for blanket coverage, and Depends for per-route precision. The chio-fastapi package exposes both. ChioMiddleware is the blanket form; chio_requires is the per-route decorator that layers on top of it:
from fastapi import FastAPI, Depends
from chio_fastapi import (
ChioMiddleware, chio_requires, get_chio_receipt, get_caller_identity,
)
app = FastAPI()
app.add_middleware(ChioMiddleware, config="./chio.yaml")
@app.get("/pets/{pet_id}")
def get_pet(
pet_id: str,
caller = Depends(get_caller_identity),
receipt = Depends(get_chio_receipt),
):
# caller.subject is "bearer:...", "apikey:...", "cookie:...", or
# "anonymous". receipt.id correlates this response with the signed
# audit record.
return {"pet_id": pet_id, "caller": caller.subject, "receipt_id": receipt.id}
@app.delete("/pets/{pet_id}")
@chio_requires(scope="pets:delete", approval=True)
def delete_pet(pet_id: str):
# With approval=True the evaluator emits a pending-approval receipt
# and waits for an operator decision before forwarding.
return {"deleted": pet_id}Go (net/http, Gin, chi)
chio-go-http exposes a Protect function that wraps any http.Handler. Gin, Echo, and chi all expose an http.Handler adapter, so the same wrapper covers them:
package main
import (
"encoding/json"
"net/http"
chio "github.com/backbay-labs/chio-go-http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/pets", func(w http.ResponseWriter, r *http.Request) {
// chio.ReceiptFrom pulls the signed HttpReceipt off the request
// context on allowed requests.
if receipt := chio.ReceiptFrom(r.Context()); receipt != nil {
w.Header().Set("x-chio-receipt-id", receipt.ID)
}
json.NewEncoder(w).Encode(map[string]any{"pets": []any{}})
})
protected := chio.Protect(mux,
chio.WithConfig("./chio.yaml"),
chio.WithSidecar("http://127.0.0.1:9091"),
)
http.ListenAndServe(":4000", protected)
}Keep the kernel out-of-process
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.
{
"id": "01HX7MZW8Q5J3K2ERT9P4A1B6C",
"request_id": "01HX7MZW7N8K2F5QT3P6A1B0VE",
"route_pattern": "/pets/{id}",
"method": "POST",
"caller_identity_hash": "9f4c...e2a1",
"session_id": null,
"verdict": { "type": "allow" },
"evidence": [
{
"guard": "http-capability",
"decision": "allow",
"details": "capability cap-pets-writer authorizes POST /pets/{id}"
}
],
"response_status": 201,
"timestamp": 1745020800,
"content_hash": "8b1c...7d33",
"policy_hash": "c7e9...02af",
"capability_id": "cap-pets-writer",
"metadata": {
"chio_http_status_scope": "final",
"chio_decision_receipt_id": "01HX7MZW8Q5J3K2ERT9P4A1B6B"
},
"kernel_key": "ed25519:f03b...91c2",
"signature": "ed25519:a3b4...c5d6"
}route_patternis the template, not the instance URL. Inchio-tower, supply this viawith_route_resolver; in the sidecar path, the pattern comes from the loaded OpenAPI spec or explicitchio.yamlroute map.caller_identity_hashis a SHA-256 hash of the extracted subject. Bearer tokens and API keys are never stored raw.content_hashcovers the canonicalized method, path, query, and body. Two requests that differ only in body bytes produce different hashes, so the receipt is bound to the exact request that was evaluated.policy_hashfingerprints 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
The policy surface is the same whether you deploy middleware or proxy. A few patterns recur often enough that they are worth calling out:
Route and method allowlist
Enumerate the routes agents may reach; anything outside the list denies even on safe methods. Narrow the method set per pattern so a route that should only see GET and POST denies PUT even with a valid capability.
http:
routes:
"GET /pets": { policy: session_allow }
"GET /pets/{id}": { policy: session_allow }
"POST /pets": { policy: deny_by_default, scope: "pets:write" }
"DELETE /pets/{id}": { policy: deny_by_default, scope: "pets:delete", approval_required: true }
default: denyBody-size caps
The evaluator receives the request body length, so policy can gate on size without inspecting content. Use this on upload endpoints where unbounded bodies are a DoS vector.
http:
routes:
"POST /uploads":
policy: deny_by_default
scope: "uploads:write"
max_body_bytes: 10485760 # 10 MiB cap, enforced before the handler runsEgress 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
max_body_bytes per route so a single oversized upload cannot eat your process memory.When to Use Middleware vs the Sidecar Proxy
| Situation | Middleware | chio 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.
Next Steps
- Architecture · how the kernel, guard pipeline, and receipt store fit together regardless of which surface feeds them.
- Protect an API · the reverse-proxy counterpart to this guide, for services you cannot modify.
- Write a Policy · HushSpec authoring. Capability scopes, approval rules, and guard configuration all live in the same policy document your middleware loads.
- 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.