How it works
This page walks one request through the whole system, then shows what happens when things go wrong. By the end you will understand the full life of a proof-bound request.
The happy path
Section titled “The happy path”sequenceDiagram autonumber participant C as Caller participant B as Broker / Issuer participant A as Adapter (verifier) participant T as Trust material + policy bundle participant U as Upstream API C->>B: "I want to call GET /orders (audience acme.demo.orders)" Note over B: Broker checks identity<br/>(software key, or SPIFFE SVID) B-->>C: Short-lived passport + transcript-v1 proof headers C->>A: GET /orders + passport + proof A->>T: Resolve issuer public key + route policy for /orders T-->>A: Public key, allowed_sources, freshness rule A->>A: 1. Match route 2. Check source rule 3. Check freshness<br/>4. Verify signature + request binding 5. Consume jti (replay) A->>U: Forward verified request (+ X-TrustPlane-* headers) U-->>C: 200 OK (business JSON)
Step by step, in plain words:
- The caller asks the broker for permission to make a specific call —
GET /ordersfor audienceacme.demo.orders. - The broker checks identity. In the simplest mode it just uses a local software key. On Kubernetes it can verify the caller’s SPIFFE/SPIRE X.509-SVID first.
- The broker returns a passport + proof headers. The proof (transcript-v1) is bound to
method
GET, path/orders, the audience, a nonce, the body hash, etc. - The caller sends the real request to the adapter, carrying the passport and proof.
- The adapter resolves trust material and policy for
/ordersfrom local files. - The adapter runs the decision pipeline (order matters — see below).
- Only then does the adapter forward the request to the upstream, adding context headers
like
X-TrustPlane-Subject. The upstream returns business JSON.
The decision pipeline (and why order matters)
Section titled “The decision pipeline (and why order matters)”The verifier evaluates checks in a deliberate order. Cheap, policy-level denials happen
before replay state is consumed, so an attacker cannot burn a victim’s jti by sending
junk:
flowchart TD
R["Incoming request + passport + proof"] --> M{"Route matches<br/>policy bundle?"}
M -- no --> Dm["deny: bundle_route_missing /<br/>bundle_policy_missing"]
M -- yes --> S{"Source rule matches?<br/>issuer / trust_domain / subject / key-binding"}
S -- no --> Ds["deny: source_*_mismatch /<br/>insufficient_key_binding"]
S -- yes --> F{"Bundle fresh enough<br/>for this route?"}
F -- no --> Df["deny: stale_bundle_fail_closed /<br/>bundle_freshness_unknown"]
F -- yes --> P{"Provenance / context<br/>policy satisfied?"}
P -- no --> Dp["deny: missing_provenance /<br/>context_mismatch ..."]
P -- yes --> V{"Passport signature +<br/>transcript-v1 binding valid?"}
V -- no --> Dv["deny: invalid_request_proof /<br/>request_binding_mismatch"]
V -- yes --> J{"jti unused?<br/>(atomic consume)"}
J -- no --> Dj["deny: jti_replay"]
J -- yes --> OK["allow → forward upstream"]
What every failure looks like
Section titled “What every failure looks like”Denials return a stable, machine-readable reason. You will see these in adapter JSON responses and audit events:
| What went wrong | Reason code |
|---|---|
| No route in the policy bundle for this method/path | bundle_route_missing |
| No policy bundle loaded at all | bundle_policy_missing |
Bundle too stale for a realtime/bounded route |
stale_bundle_fail_closed |
| Unknown/invalid freshness class | bundle_freshness_unknown |
| Issuer not allowed for this route | source_issuer_mismatch |
| Trust domain not allowed | source_trust_domain_mismatch |
| Subject not allowed | source_subject_mismatch |
Key not strong enough (e.g. needs attested_workload) |
insufficient_key_binding |
| Required provenance/context missing or wrong | missing_provenance, provenance_mismatch, missing_context, context_mismatch |
| Proof signature malformed / does not verify | invalid_request_proof |
| Request does not match the signed transcript (tampered path/nonce/route) | request_binding_mismatch |
| Same passport/proof presented twice | jti_replay |
(Adapter HTTP) missing Authorization |
401 |
| (Adapter HTTP) wrong audience | 403 |
Where verification can happen
Section titled “Where verification can happen”The same passport + proof semantics work at any enforcement point:
flowchart LR
C["Caller / agent"] --> E{"Enforcement point"}
E --> A1["Brownfield adapter<br/>(reverse proxy)"]
E --> A2["Go middleware<br/>(pkg/middleware/http)"]
E --> A3["Gateway / sidecar"]
A1 --> API["Protected API"]
A2 --> API
A3 --> API
That portability is the point: you do not have to rewrite your API to adopt TrustPlane Auth. The most common starting point is the brownfield adapter — covered in depth in the Deployment overview.
Local-first, offline-capable
Section titled “Local-first, offline-capable”Notice what is not in the happy path: there is no mandatory call to a central server during verification. The verifier only needs public trust material and route policy, both of which are local files. This is why TrustPlane Auth can protect APIs at the edge, in air-gapped environments, and in CI — and why a single bundle file can authorize many issuers and clients.
Next steps
Section titled “Next steps”- Get hands-on: Quickstart
- Put an adapter in front of a real API: Deployment overview