Skip to content

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.

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:

  1. The caller asks the broker for permission to make a specific call — GET /orders for audience acme.demo.orders.
  2. 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.
  3. 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.
  4. The caller sends the real request to the adapter, carrying the passport and proof.
  5. The adapter resolves trust material and policy for /orders from local files.
  6. The adapter runs the decision pipeline (order matters — see below).
  7. 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"]

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

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.

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.