Chio/Docs

BuildConnect

Protect an API

Place a reverse proxy before an HTTP service to check requests against an OpenAPI spec and sign a receipt for each call.

Prerequisites

This guide assumes you have the chio CLI installed. If not, see the Installation guide. You also need an upstream HTTP service reachable from the host running chio, and optionally an OpenAPI spec. If you do not have a spec locally, chio will try to auto-discover one from the upstream.

Why Protect an API with Chio

Autonomous workflows make API calls. When a 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 runs as a sidecar between the caller and upstream service. It performs four operations:

  • 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 HttpReceipt with 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 behavior 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

IngredientPurposeRequired
Upstream URLBase URL of the HTTP service Chio will proxy to.Yes
OpenAPI specDefines route patterns and methods. Auto-discovered from the upstream when omitted.Recommended
Listen addressWhere chio binds for inbound requests. Defaults to 127.0.0.1:9090.No
Receipt storeSQLite path for persisting signed receipts across restarts.Yes (or opt in to ephemeral)
Authority seedStable 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 names an upstream and a durable receipt store:

bash
$ chio api protect --upstream <url> --receipt-store <path> [--spec <file>] [--listen <addr>]

The receipt store is not optional at boot. chio api protect refuses to start without --receipt-store ("refusing to start without durable receipts") unless you explicitly pass --allow-ephemeral-receipts to accept in-memory receipts that are lost on every restart — a local-development opt-in only.

A production-shaped invocation with the common flags:

bash
$ 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.sqlite \
    --upstream-timeout-secs 20

Each flag in turn:

FlagDefaultMeaning
--upstreamrequiredBase URL of the service to protect. Path and query of each inbound request are appended when forwarding.
--specauto-discoverPath to a local OpenAPI (YAML or JSON) spec. If omitted, Chio probes well-known locations on the upstream.
--listen127.0.0.1:9090Address chio binds for inbound proxy traffic.
--receipt-storerequired at bootSQLite path for durable receipt persistence. The command refuses to start without it unless --allow-ephemeral-receipts is set.
--allow-ephemeral-receiptsfalsePermit in-memory receipts, whose audit evidence is lost on every restart. Required to boot without --receipt-store. Local development only.
--upstream-timeout-secs20Wall-clock ceiling in seconds on a single upstream hop, including reading the full response.
--authority-seed-fileephemeralTop-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:

bash
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.sqlite

Zero-config quickstart with chio start

For SDK quickstart and local development, chio start is a convenience alias for chio api protect with zero-config defaults. It serves the same sidecar router (capability mint, release, and validate; receipt verify; tool-call evaluate; and the human-in-the-loop approval endpoints) but runs no upstream proxy — the catch-all route returns 502 rather than forwarding — and turns on durable receipts by default (pass --allow-ephemeral-receipts for an in-memory run). Reach for chio api protect once you need production reverse-proxying with --upstream and --spec.

How HTTP Requests Flow Through Chio

Each request follows the route match, identity, policy, capability, and receipt steps shown below. The proxy fails closed and records allow and deny decisions.

rendering…
Every request walks the same pipeline. Route match, identity, policy, capability, and a decision-scope receipt fire before the verdict. Allow forwards upstream and signs a final-scope receipt on the response; deny returns 403 with the receipt persisted.

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) get SessionAllow and pass through with an audit receipt.
  • Side-effect methods (POST, PUT, PATCH, DELETE) get DenyByDefault and require a valid capability token in X-Chio-Capability.

An unknown side-effect route does not fall through to the upstream. The proxy records the denial.


Writing Policies for HTTP

Chio derives HTTP policy from 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:

MethodDefault policyCapability required
GET, HEAD, OPTIONSSessionAllowNo
POST, PUT, PATCH, DELETEDenyByDefaultYes

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.yaml
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: sensitive
      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, sensitive, restricted). Consumed by the guard pipeline for logging level and audit granularity; it does not change policy directly.
  • x-chio-side-effects: explicit boolean override. Flip a GET into a guarded route when it returns sensitive data, or flip a POST into 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, in minor currency units, that a single invocation may declare. Parsed from the spec and recorded for budget accounting; the gateway does not currently emit a dedicated budget-guard evidence entry.
  • x-chio-publish: whether to include the operation in chio's generated tool manifest. Set to false for 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

Chio derives policy from the OpenAPI spec. If a route is absent, Chio treats it as unknown and denies side effects by default. Keep the spec current because it controls route policy as well as client documentation.

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-Capability header or the chio_capability query parameter.

Chio extracts a caller identity hash from standard auth signals so the receipt binds which caller invoked which capability. The precedence is:

  1. Authorization: Bearer <token>: chio hashes the token (never stores it) and emits a caller subject like bearer:3a7c8f...
  2. X-Api-Key (or variants): chio hashes the key value and emits apikey:...
  3. Neither present: caller is anonymous in 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.

This request satisfies both layers:

bash
$ 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:

receipt.json
{
  "id": "01HX7MZW8Q5J3K2ERT9P4A1B6C",
  "request_id": "01HX7MZW7N8K2F5QT3P6A1B0VE",
  "route_pattern": "/orders/{orderId}/refund",
  "method": "POST",
  "caller_identity_hash": "9f4c...e2a1",
  "session_id": "sess-ord-ui-01",
  "verdict": { "verdict": "allow" },
  "evidence": [
    {
      "guard_name": "CapabilityGuard",
      "verdict": true,
      "details": "valid capability token presented"
    }
  ],
  "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: either decision or final. Final receipts also carry chio_decision_receipt_id pointing 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). The listing also fails closed on tenant scope: pass --tenant <id> or --admin-all, or it refuses rather than defaulting to every tenant. There is no dedicated route-pattern filter: shape the query by server plus tool and refine with jq.

bash
# List HTTP receipts for the orders sidecar. Default output is
# JSON Lines (one receipt per line). A tenant boundary is required.
$ chio --receipt-db ./receipts.sqlite receipt list \
    --tenant acme-42 --tool-server orders-api --limit 50

# Narrow to a specific route pattern using jq:
$ chio --receipt-db ./receipts.sqlite receipt list \
    --tenant acme-42 --tool-server orders-api --limit 500 \
    | jq 'select(.route_pattern == "/orders/{orderId}/refund")'

Sensitive headers are not in the receipt, but do not be casual

Chio never writes raw 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

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.

bash
$ chio api protect --upstream https://catalog.internal:8080 \
    --spec ./catalog.openapi.yaml \
    --listen 0.0.0.0:9090 \
    --receipt-store ./catalog-receipts.sqlite

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

tenant-cap-scope.yaml
# 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: 25000

A request for POST /tenants/other-99/orders/ord-1/refund presented with this capability will deny at the CapabilityGuard: 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 same controls apply with a different 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.

bash
# 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.json

Sensitive reads require capability

Some GET endpoints return sensitive data and should not be treated as safe. Annotate them in the spec:

openapi-sensitive.yaml
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.


The Sidecar Wire Protocol

The same binary that runs the reverse proxy also speaks a small JSON wire protocol on the sidecar listener, which is how SDK middleware evaluates a request in-process. Three endpoints, all JSON over the default 127.0.0.1:9090 listener (override with the SDK's sidecarUrl or the CHIO_SIDECAR_URL env var):

  • POST /chio/evaluate returns { verdict, receipt, evidence }. It responds with HTTP 200 for both allow and deny — the verdict field carries the outcome, not the status code. A middleware fails closed on any other status.
  • POST /chio/verify returns { valid: boolean }, an Ed25519 signature check on a receipt. Signature validity is not temporal validity: a valid signature on an expired receipt is still valid: true.
  • GET /chio/health returns { status, version }, and 503 when unhealthy.

SDK middleware defaults to a 5000ms timeout on these calls and must fail closed when the sidecar is unreachable or times out. An SDK may expose a fail-open override, but the default is fail-closed: an unreachable sidecar never lets the request through.


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

Aspectchio api protect (gateway)SDK middleware (in-process)
Deploy shapeSeparate process in front of your serviceOne package added to your service
Extra hopYes (proxy forwards to upstream)No (evaluates inline)
LanguageLanguage-agnostic; anything that speaks HTTPRequires an SDK for your framework
Policy sourceOpenAPI spec plus x-chio-* extensionsSame policy via the sidecar, plus native route decorators and DI
Sidecar dependencySelf-contained binaryMiddleware calls a local Chio sidecar over HTTP
Best forServices you cannot modify; multi-tenant edge gatewaysServices that can add a Chio dependency and use framework-specific bindings

Fail-closed in both modes

Whether you run the gateway or the middleware, a denied request never reaches your business logic. The middleware returns a 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 middleware forwards each request to a local chio sidecar for evaluation; its config option points that sidecar at a chio.yaml runtime config (kernel, adapters, edges, receipts), a separate artifact from the OpenAPI spec the gateway consumes:

typescript
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) => {
  // The middleware only reaches this handler on allow, so
  // req.chioResult.verdict is the { verdict: "allow" } variant. verdict is a
  // tagged union, not a string: narrow it with the SDK's
  // isAllowed(req.chioResult.verdict) rather than comparing to "allow". The
  // signed HttpReceipt is on req.chioResult.receipt.
  res.json({ deleted: req.params.id });
});

app.listen(4000);

Fastify (TypeScript / Node)

typescript
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 is the EvaluateResponse: { verdict, receipt?, evidence }.
  // Caller identity rides on the signed receipt as a hash — there is no
  // chioResult.caller. Pull it from the receipt instead.
  return {
    order_id: "ord_123",
    caller: request.chioResult?.receipt?.caller_identity_hash,
  };
});

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.

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

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

CapabilityHow it is delivered
Route-aware policyOpenAPI spec plus x-chio-* extensions; no per-route HushSpec rules required.
Capability-scoped writesSide-effect methods deny by default; X-Chio-Capability token gates each call.
Signed receiptsEd25519-signed HttpReceipt per call; decision plus final scopes.
Auth pass-throughUpstream still sees its own Authorization/API-key headers unchanged.
Zero upstream changesThe proxy is transparent; the service behind Chio is unmodified.

Next Steps

  • Write a Policy · 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 CapabilityGuard and DefaultPolicyGuard compose into the HTTP evaluation pipeline.
  • Envoy ext_authz · run chio as an external authorization service behind Envoy instead of as a standalone proxy.