Protect an API
Chio is not only for MCP and ACP. The chio api protect command fronts any plain HTTP or REST service as a transparent reverse proxy, evaluates every request against a policy derived from your OpenAPI spec, and emits a signed receipt per call. Safe reads flow through as audit; side-effect writes require a capability token. The upstream never learns chio is there.
Prerequisites
Why Protect an API with Chio
Agent traffic is API traffic. The moment an autonomous workflow can issue a POST /orders or a DELETE /tenants/{id}, your existing API gateway stops being enough. Gateways authenticate, rate limit, and terminate TLS; they do not reason about whether this specific call was authorized by a capability the operator issued, or whether the response leaks into a non-repudiable audit trail.
chio api protect is a zero-code sidecar that sits between the caller and the upstream service. It does four things:
- Discovers routes: parses your OpenAPI spec (or fetches one from the upstream) to build a typed route table keyed on method plus path pattern.
- Evaluates every request: matches each inbound call to a route, classifies it safe or side-effect, and checks for a presented capability when required.
- Signs a receipt: every allowed or denied call produces a signed
HttpReceiptwith caller identity hash, route pattern, verdict, guard evidence, and policy hash, all bound under an Ed25519 signature. - Proxies transparently: the upstream receives the original request, unmodified except for stripped capability headers. Clients observe no behaviour change on allowed paths.
Because chio keys on the OpenAPI contract, you do not write a policy file per endpoint. The default rule set is derived from HTTP semantics: safe methods pass, side-effect methods need authorization. You then sharpen that with OpenAPI extensions or explicit overrides.
What You Need
| Ingredient | Purpose | Required |
|---|---|---|
| Upstream URL | Base URL of the HTTP service chio will proxy to. | Yes |
| OpenAPI spec | Defines route patterns and methods. Auto-discovered from the upstream when omitted. | Recommended |
| Listen address | Where chio binds for inbound requests. Defaults to 127.0.0.1:9090. | No |
| Receipt store | SQLite path for persisting signed receipts across restarts. | Recommended |
| Authority seed | Stable Ed25519 seed so the receipt kernel key does not rotate on restart. | Recommended for production |
If you do not yet have an OpenAPI spec, chio can still protect the service. Unknown routes fall back to a method-based default: safe methods (GET, HEAD, OPTIONS) are allowed; every other method denies by default. This is safe-by-default but coarse. Point chio at a spec as soon as you can.
The Protect Command
The canonical invocation is short:
$ chio api protect --upstream <url> [--spec <file>] [--listen <addr>] [--receipt-store <path>]A production-shaped invocation with every flag set:
$ chio --authority-seed-file ./chio-seed.hex \
api protect \
--upstream https://orders.internal:8443 \
--spec ./openapi.yaml \
--listen 0.0.0.0:9090 \
--receipt-store ./receipts.sqliteEach flag in turn:
| Flag | Default | Meaning |
|---|---|---|
--upstream | required | Base URL of the service to protect. Path and query of each inbound request are appended when forwarding. |
--spec | auto-discover | Path to a local OpenAPI (YAML or JSON) spec. If omitted, chio probes well-known locations on the upstream. |
--listen | 127.0.0.1:9090 | Address chio binds for inbound proxy traffic. |
--receipt-store | in-memory | SQLite path for durable receipt persistence. Without this flag, receipts live only in process memory. |
--authority-seed-file | ephemeral | Top-level chio flag. Hex-encoded Ed25519 seed that stabilizes the kernel signing key across restarts. |
When chio starts, you will see it load or discover the spec, build the route table, and bind the listener:
INFO loaded OpenAPI spec
source: ./openapi.yaml
paths: 14, operations: 27
INFO built route table
routes: 27 (8 safe, 19 side-effect)
INFO chio api protect ready
upstream: https://orders.internal:8443
listening: 0.0.0.0:9090
receipts: ./receipts.sqliteHow HTTP Requests Flow Through Chio
Every request follows the same pipeline. The pipeline is deterministic, fail-closed, and produces a receipt whether the verdict is allow or deny.
The route matcher is literal. It walks the route table in order, matching both the HTTP method and a segmented path pattern that supports {param} placeholders. A request for GET /pets/42 matches the route GET /pets/{petId} and inherits that route's policy. Matching is case-sensitive and rejects trailing-slash mismatches, so /pets/ and /pets are distinct patterns.
If no route matches, chio falls back to a method-based default:
- Safe methods (
GET,HEAD,OPTIONS) getSessionAllowand pass through with an audit receipt. - Side-effect methods (
POST,PUT,PATCH,DELETE) getDenyByDefaultand require a valid capability token inX-Chio-Capability.
This matters: unknown routes with side-effect methods do not fall through to the upstream just because they were not enumerated in the spec. The denial is recorded as evidence.
Writing Policies for HTTP
Chio's HTTP policy surface is driven by the OpenAPI spec plus the x-chio-* extension fields. You are not authoring HushSpec rules per route: you annotate the spec and chio derives the policy. The default rule set is:
| Method | Default policy | Capability required |
|---|---|---|
GET, HEAD, OPTIONS | SessionAllow | No |
POST, PUT, PATCH, DELETE | DenyByDefault | Yes |
Override per operation by adding x-chio-* fields. Here is a spec fragment that opts a GET into deny-by-default (because it returns sensitive data) and requires explicit human approval on a DELETE:
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
paths:
/orders:
get:
operationId: listOrders
summary: List orders in the current tenant.
responses:
"200": { description: OK }
/orders/{orderId}:
get:
operationId: getOrder
x-chio-sensitivity: confidential
x-chio-side-effects: true
responses:
"200": { description: OK }
delete:
operationId: deleteOrder
x-chio-approval-required: true
x-chio-budget-limit: 10000
responses:
"204": { description: Deleted }
/orders/{orderId}/refund:
post:
operationId: refundOrder
x-chio-side-effects: true
x-chio-budget-limit: 50000
responses:
"200": { description: Refund issued }The extension fields chio recognizes at the operation level:
x-chio-sensitivity: data classification (public,internal,confidential,restricted). Surfaces into receipt evidence so downstream audit can filter by class.x-chio-side-effects: explicit boolean override. Flip aGETinto a guarded route when it returns sensitive data, or flip aPOSTinto a safe audit-only route when it is truly idempotent.x-chio-approval-required: when true, chio pauses the call and emits a pending-approval receipt rather than proxying. See Human-in-the-Loop for the approval flow.x-chio-budget-limit: maximum cost (minor currency units) a single invocation may consume. Enforced against budget records attached to the capability.x-chio-publish: whether to include the operation in chio's generated tool manifest. Set tofalsefor ops that exist on the upstream but should remain hidden from agents.
For capability design, think in terms of method plus path pattern. A capability's HTTP scope is a list of method, route-pattern tuples such as GET /users/* or POST /orders/{orderId}/refund. Prefer the narrowest pattern that covers the legitimate workflow: a capability scoped to POST /orders/*/refund is broader than one scoped to a single orderId, and the receipt log will show which was used.
Start with the spec, not with rules
Handling Authentication
Chio does not replace your upstream's authentication. It layers capability authorization on top. The distinction matters:
- Authentication: who is the caller. Handled by the upstream via
Authorization: Bearer ..., API keys, mTLS, or whatever the service already requires. - Capability authorization: does this caller hold a signed grant to perform this action on this resource right now. Handled by chio via the
X-Chio-Capabilityheader or thechio_capabilityquery parameter.
Chio extracts a caller identity hash from standard auth signals so the receipt binds which caller invoked which capability. The precedence is:
Authorization: Bearer <token>: chio hashes the token (never stores it) and emits a caller subject likebearer:3a7c8f...X-Api-Key(or variants): chio hashes the key value and emitsapikey:...- Neither present: caller is
anonymousin the receipt.
Auth headers flow through unchanged. Upstream still sees the original Authorization and validates it. Chio strips only the capability envelope: X-Chio-Capability on the header side, and the chio_capability query parameter on the URL side, so the upstream never sees chio metadata.
A concrete request that satisfies both layers:
$ curl -X POST https://chio.internal:9090/orders/ord-42/refund \
-H "Authorization: Bearer upstream-user-token-abc" \
-H "X-Chio-Capability: $(cat ./refund-cap.jws)" \
-H "Content-Type: application/json" \
-d '{"amount_cents": 4200, "reason": "duplicate charge"}'
{"status": "refund_issued", "refund_id": "rf-99"}In the receipt, the caller identity hash binds to bearer:... and the capability ID binds to the specific JWS presented. The upstream received Authorization: Bearer upstream-user-token-abc and no chio-specific headers.
Receipts for HTTP Calls
Every evaluated request produces at least one HttpReceipt. Allowed requests produce two: a decision receipt signed before the upstream call, and a final receipt signed after the upstream response status is known. The final receipt references the decision receipt by ID, so you can reconstruct the full call chain from any starting point.
A receipt for an allowed refund POST looks like this:
{
"id": "01HX7MZW8Q5J3K2ERT9P4A1B6C",
"request_id": "01HX7MZW7N8K2F5QT3P6A1B0VE",
"route_pattern": "/orders/{orderId}/refund",
"method": "POST",
"caller_identity_hash": "9f4c...e2a1",
"session_id": "sess-ord-ui-01",
"verdict": { "type": "allow" },
"evidence": [
{
"guard": "http-capability",
"decision": "allow",
"details": "capability cap-refund-42 authorizes POST /orders/{orderId}/refund"
},
{
"guard": "http-budget",
"decision": "allow",
"details": "invocation cost 4200 under limit 50000"
}
],
"response_status": 200,
"timestamp": 1745020800,
"content_hash": "8b1c...7d33",
"policy_hash": "c7e9...02af",
"capability_id": "cap-refund-42",
"metadata": {
"chio_http_status_scope": "final",
"chio_decision_receipt_id": "01HX7MZW8Q5J3K2ERT9P4A1B6B"
},
"kernel_key": "ed25519:f03b...91c2",
"signature": "ed25519:a3b4...c5d6"
}Fields of note:
route_pattern: the matched OpenAPI pattern, not the literal URL. This keeps cardinality bounded for downstream analytics.caller_identity_hash: SHA-256 hash of the extracted caller identity. Bound to the capability, unguessable from the receipt alone.content_hash: SHA-256 over the canonicalized request (method, path, query, body hash). Binds the receipt to the exact request bytes.policy_hash: SHA-256 of the spec content chio loaded at start. If you rotate the spec, the hash changes and downstream verifiers notice.metadata.chio_http_status_scope: eitherdecisionorfinal. Final receipts also carrychio_decision_receipt_idpointing at the earlier record.
Query receipts just like MCP receipts:
The chio receipt list filters are: --capability, --tool-server, --tool-name, --outcome, --since, --until, --min-cost, --max-cost, and paging (--limit, --cursor). There is no dedicated route-pattern filter: shape the query by server plus tool and refine with jq.
# List HTTP receipts for the orders sidecar. Default output is
# JSON Lines (one receipt per line).
$ chio --receipt-db ./receipts.sqlite receipt list \
--tool-server orders-api --limit 50
# Narrow to a specific route pattern using jq:
$ chio --receipt-db ./receipts.sqlite receipt list \
--tool-server orders-api --limit 500 \
| jq 'select(.metadata.http.route_pattern == "/orders/{orderId}/refund")'Sensitive headers are not in the receipt, but do not be casual
Authorization or X-Api-Key values into receipts. Only hashes reach the signed body. That said, your upstream may log request bodies that contain secrets, and chio does not sanitize those. Treat the upstream log pipeline with the same rigor you give chio receipts. For fields you know contain PII or credentials, prefer schema-level hashing upstream of the proxy so the plaintext never reaches disk.Common Patterns
Public read, authorized write
The simplest useful shape. Reads are audit-only; writes require a capability. This is the default behavior, so no spec annotations are needed: just run chio api protect against your upstream.
$ chio api protect --upstream https://catalog.internal:8080 \
--spec ./catalog.openapi.yaml \
--listen 0.0.0.0:9090 \
--receipt-store ./catalog-receipts.sqliteAgents can freely call GET /products and GET /products/{sku}; every call writes an audit receipt. Any POST, PATCH, or DELETE requires a signed capability and fails closed with a 403 receipt when absent.
Multi-tenant path prefixes
Scope capabilities to tenant path prefixes so an agent with one tenant's capability cannot accidentally touch another's data. Capability scopes are expressed as method-plus-pattern tuples; the pattern supports {param} placeholders and literal segments:
# Embedded inside a capability token's scope.grants
- server_id: orders-api
http_scope:
# Agent can read any resource under tenant acme-42...
- method: GET
pattern: /tenants/acme-42/**
# ...and issue refunds only within that tenant.
- method: POST
pattern: /tenants/acme-42/orders/{orderId}/refund
max_invocations: 100
max_total_cost: 25000A request for POST /tenants/other-99/orders/ord-1/refund presented with this capability will deny at the http-capability guard: the route pattern does not match the granted scope. The receipt records the exact mismatch in its evidence.
Webhook and egress control
Point chio the other direction: an agent that needs to POST to a third-party webhook can be routed through a chio-protected egress proxy. The pattern is identical; the policy surface just changes audience. Give the capability a narrow http_scope pinning the exact webhook URL pattern and a conservative max_invocations. Because receipts bind the full request hash, any exfiltration attempt through unapproved URLs will either deny at scope check or leave a non-repudiable record.
# Egress proxy in front of a stripe-like webhook target
$ chio api protect --upstream https://api.thirdparty.example \
--spec ./thirdparty-webhook.openapi.yaml \
--listen 127.0.0.1:9191 \
--receipt-store ./egress-receipts.sqlite
# Agent calls localhost; chio checks capability, forwards outbound
$ curl -X POST http://127.0.0.1:9191/v1/webhooks/orders \
-H "X-Chio-Capability: $(cat ./webhook-cap.jws)" \
-H "Content-Type: application/json" \
-d @event.jsonSensitive reads require capability
Some GET endpoints return sensitive data and should not be treated as safe. Annotate them in the spec:
paths:
/users/{userId}/ssn:
get:
operationId: getUserSsn
x-chio-sensitivity: restricted
x-chio-side-effects: true
x-chio-approval-required: true
responses:
"200": { description: OK }Now the route is treated as side-effect: a capability is required, and because x-chio-approval-required is set, chio emits a pending-approval receipt on first invocation and waits for an operator decision before forwarding.
Alternative: In-process Middleware
The gateway approach above (chio api protect) runs chio as a separate process in front of your service. The SDK middleware approach moves the same enforcement logic inside your application, as a package you import. Both end up with the same signed HttpReceipt on every call; the question is where the enforcement happens.
When to pick which
| Aspect | chio api protect (gateway) | SDK middleware (in-process) |
|---|---|---|
| Deploy shape | Separate process in front of your service | One package added to your service |
| Extra hop | Yes (proxy forwards to upstream) | No (evaluates inline) |
| Language | Language-agnostic; anything that speaks HTTP | Requires an SDK for your framework |
| Policy surface | OpenAPI spec plus x-chio-* extensions | Same policy file, plus native route decorators and DI |
| Sidecar dependency | Self-contained binary | Middleware calls a local chio sidecar over HTTP |
| Best for | Services you cannot modify; multi-tenant edge gateways | New apps where chio is a first-class dependency; teams who want type-safe bindings in their framework |
Fail-closed in both modes
403 with the same structured ChioErrorResponse body the gateway emits.Express (TypeScript / Node)
The @chio-protocol/express package exports a chio() middleware and a matching error handler. The same chio.yaml policy file used by the gateway is read by the middleware:
import express from "express";
import { chio, chioErrorHandler } from "@chio-protocol/express";
const app = express();
app.use(chio({ config: "./chio.yaml" }));
app.use(chioErrorHandler);
app.get("/health", (_req, res) => res.json({ ok: true }));
app.post("/users/:id/delete", (req, res) => {
// At this point req.chioResult.verdict === "allow" and the signed
// HttpReceipt is on req.chioResult.receipt.
res.json({ deleted: req.params.id });
});
app.listen(4000);Fastify (TypeScript / Node)
import Fastify from "fastify";
import { chio } from "@chio-protocol/fastify";
const fastify = Fastify();
await fastify.register(chio, { config: "./chio.yaml" });
fastify.post("/orders", async (request) => {
// request.chioResult carries the verdict, caller identity, and receipt.
return { order_id: "ord_123", caller: request.chioResult.caller.subject };
});
await fastify.listen({ port: 4000 });FastAPI (Python)
chio-fastapi leans into Python-native patterns: route decorators for declaring capability requirements and dependency injection for receipts and caller identity.
from fastapi import FastAPI, Depends, Request
from chio_fastapi import chio_requires, chio_approval, get_caller_identity
app = FastAPI()
@app.get("/users/{user_id}")
@chio_requires("users-api", "read", ["Invoke"])
async def get_user(
user_id: str,
request: Request,
caller = Depends(get_caller_identity),
):
return {
"user_id": user_id,
"caller": caller.subject,
}
# Stack @chio_approval on top of @chio_requires to gate writes
# above a monetary threshold on an operator-issued approval token.
@app.post("/users/{user_id}")
@chio_approval(threshold_cents=0)
@chio_requires("users-api", "write", ["Invoke"])
async def update_user(user_id: str, body: dict, request: Request):
return {"updated": user_id}Go (net/http)
chio-go-http exposes a single Protect function that wraps any http.Handler. Compatible with gorilla/mux, chi, and Gin via their http.Handler adapters.
package main
import (
"net/http"
chio "github.com/backbay-labs/chio/sdks/go/chio-go-http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/orders", func(w http.ResponseWriter, r *http.Request) {
// On allow, the wrapper sets X-Chio-Receipt-Id on the response
// so downstream logging can correlate the call to its receipt.
w.Write([]byte("ok"))
})
protected := chio.Protect(mux, chio.ConfigFile("./chio.yaml"))
http.ListenAndServe(":4000", protected)
}Other frameworks
Additional first-party middleware packages ship alongside the SDKs:
- TypeScript:
@chio-protocol/elysia,@chio-protocol/node-http(the framework-agnostic base). - Python:
chio-django,chio-asgi(for Starlette, Sanic, and other ASGI frameworks). - Rust: the core chio SDK ships with axum and tower extractors; see the Rust SDK reference.
See each SDK's reference page for the full API: TypeScript SDK, Python SDK, Go SDK, Rust SDK.
Summary
Protecting an API with chio gives you:
| Capability | How it is delivered |
|---|---|
| Route-aware policy | OpenAPI spec plus x-chio-* extensions; no per-route HushSpec rules required. |
| Capability-scoped writes | Side-effect methods deny by default; X-Chio-Capability token gates each call. |
| Signed receipts | Ed25519-signed HttpReceipt per call; decision plus final scopes. |
| Auth pass-through | Upstream still sees its own Authorization/API-key headers unchanged. |
| Zero upstream changes | The proxy is transparent; the service behind chio is unmodified. |
Next Steps
- Write a Policy · comprehensive guide to HushSpec authoring. Even though HTTP policy is spec-driven, your capability scopes and approval rules still live in HushSpec.
- Wrap an MCP Server · the MCP analog of this guide. Same receipt model, different transport.
- Guards · how the
http-capability,http-budget, and related guards compose into the HTTP evaluation pipeline. - Envoy ext_authz · run chio as an external authorization service behind Envoy instead of as a standalone proxy.