Chio/Docs

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

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, one policy engine. Middleware evaluates inline and calls through to your handler; the proxy terminates the request and forwards to an unmodified upstream. Receipts flow to the same store in both cases.
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 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 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.

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

src/main.rs
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:

  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.rs
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-tower crate works with replayable tower body types including axum::body::Body and bytes-backed HTTP bodies. Real tonic::body::Body replay 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 fn pointers, 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

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.

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

src/server.ts
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:

app/main.py
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:

cmd/server/main.go
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

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

receipt.json
{
  "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_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 or explicit chio.yaml route map.
  • caller_identity_hash is a SHA-256 hash of the extracted subject. Bearer tokens and API keys are never stored raw.
  • content_hash covers 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_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

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.

chio.yaml
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: deny

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

chio.yaml
http:
  routes:
    "POST /uploads":
      policy: deny_by_default
      scope: "uploads:write"
      max_body_bytes: 10485760  # 10 MiB cap, enforced before the handler runs

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 max_body_bytes per route 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.


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.
Add Chio Middleware to Your HTTP Framework · Chio Docs