---
id: 2
title: "A Security Token Service for workload identity"
version: "1.0.0"
status: stable
date: 2026-07-27
authors:
  - "Theo Zourzouvillys"
tags: [security, auth, identity, tokens, infra, sts]
summary: "A complete specification for a workload STS: platform attestation as the only bootstrap, RFC 8693 token exchange, downscope-only policy, short-lived at+jwt tokens bound to a holder key, KMS signing keys with overlapping rotation, and local verification."
use_when: "Services in your platform need to authenticate to each other and you want every call attributable to a named workload holding no long-lived secret."
not_for:
  - "End-user authentication, sessions, or consent — this issues machine credentials, not user logins."
  - "Authorisation policy itself; the STS decides what a workload may be *issued*, not what it may *do* once inside a service."
  - "A platform with one runtime and one service, where the token exchange costs more than it returns."
requires: [1]
provides:
  - "workload-identity"
  - "token-exchange"
  - "short-lived-service-credentials"
implements_notes: [4, 5, 9, 16, 38, 40, 49]
supersedes: null
superseded_by: null
aliases: []
crossrefs:
  ZFN-4: "Directly behind ZBP-2-R48: an STS becomes a tier-zero dependency, so break-glass access cannot route through it."
  ZFN-5: "The position this blueprint implements. That note argues for one shared token service rather than per-service identity; this specifies what to build."
  ZFN-9: "The reason ZBP-2-R12 insists on a platform-rooted attestor: nothing should have to be handed a secret in order to obtain its first token."
  ZFN-16: "The rationale for ZBP-2-R46: issuance runs from pushed, cached config and never calls the control plane."
  ZFN-38: "The basis for the actor-token and delegation-claim requirements rather than credential copying."
  ZFN-40: "Why every issuance names a principal and why the audit record in ZBP-2-R51 carries both subject and actor."
  ZFN-49: "Two requirements come straight from it: local verification against a cached JWKS instead of an introspection call per request, and storing the rare revocations rather than a row per issuance."
  ZBP-1: "The holder-binding mechanism this STS issues tokens for. ZBP-2 sets the confirmation claim; ZBP-1 specifies what a resource server then does with it, including the thumbprint comparison that makes binding meaningful."
references:
  - id: rfc8693
    title: "RFC 8693 — OAuth 2.0 Token Exchange"
    url: https://www.rfc-editor.org/rfc/rfc8693
    abstract: "Defines a grant type for trading one security token for another: the caller presents a subject token (who it is), optionally an actor token (who is acting for it), and asks for a token scoped to a named audience. Also defines the `act` and `may_act` claims that make delegation explicit and traceable."
  - id: rfc9068
    title: "RFC 9068 — JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens"
    url: https://www.rfc-editor.org/rfc/rfc9068
    abstract: "Pins down what an access token looks like when it is a JWT: the `at+jwt` media type so it can't be confused with an ID token, the required claims, and the validation a resource server must perform. Removes the ambiguity that made every deployment's access token subtly different."
  - id: rfc7638
    title: "RFC 7638 — JSON Web Key (JWK) Thumbprint"
    url: https://www.rfc-editor.org/rfc/rfc7638
    abstract: "Defines a canonical hash of a JWK: take only the required members for the key type, sort them lexicographically, serialise as JSON with no whitespace, and hash. A stable key identifier anyone can recompute, which is why it makes a better `kid` than a hand-assigned name."
  - id: rfc8414
    title: "RFC 8414 — OAuth 2.0 Authorization Server Metadata"
    url: https://www.rfc-editor.org/rfc/rfc8414
    abstract: "A well-known JSON document describing an authorization server: its issuer, endpoints, JWKS location, and supported algorithms and grant types. Lets a client discover and validate configuration instead of having it hard-coded in every service."
  - id: rfc8725
    title: "RFC 8725 — JSON Web Token Best Current Practices"
    url: https://www.rfc-editor.org/rfc/rfc8725
    abstract: "The accumulated hard-won guidance on not getting JWTs wrong: always verify the algorithm against an expected set rather than trusting the header, avoid algorithm confusion, validate every claim you rely on, and use explicit typing to stop one kind of token being accepted where another was meant."
  - id: spiffe-id
    title: "SPIFFE ID — Secure Production Identity Framework for Everyone"
    url: https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md
    abstract: "A naming scheme for workload identity: `spiffe://trust-domain/path`, where the trust domain is the root of trust and the path names the workload within it. Deliberately opaque to the verifier beyond its structure, so identity strings stay stable while the infrastructure behind them changes."
  - id: rfc7523
    title: "RFC 7523 — JSON Web Token Profile for OAuth 2.0 Client Authentication and Authorization Grants"
    url: https://www.rfc-editor.org/rfc/rfc7523
    abstract: "How a JWT issued by a trusted party can stand in for a client secret or an authorization grant. The basis for federating an external identity provider's assertion into your own token service without ever provisioning a shared secret."
---

## TL;DR

This specifies a **Security Token Service**: one small, shared service that turns the identity a
workload already has by virtue of *where it runs* into a short-lived, audience-scoped token it can use
to call other services. Nothing in the platform holds a long-lived credential, every call is
attributable to a named workload, and every service verifies tokens locally without calling anything.

The mechanism is [RFC 8693 token exchange](ref:rfc8693) issuing
[RFC 9068 `at+jwt`](ref:rfc9068) access tokens, optionally bound to a holder key via
[ZBP-1](/zbp/1-dpop-bound-requests/). This document specifies the attestation model that bootstraps
trust without a secret, the policy rules that make exchange safe, the token format, signing key
handling, how relying parties verify, and how revocation works when the primary control is a
five-minute lifetime.

The hard part of an STS is not the protocol. It is that this service becomes the thing every other
service depends on, so its availability, its blast radius, and its bootstrap are the design.

## Applicability

**Use this when** you have more than a handful of services that call each other; when you want to
delete static credentials from config, secret stores, and CI; when "which service did this?" needs an
answer in the audit log; or when you need to hand a workload a narrowly-scoped, expiring credential
for a specific downstream call rather than a standing grant.

**Do not use this when:**

- **You are authenticating end users.** This is machine identity. Sessions, consent, MFA, account
  recovery, and everything else about human authentication is a different problem with different
  failure modes. A workload STS may sit *behind* a user-facing identity system, but it is not one.
- **You want an authorisation engine.** The STS decides what a workload may be *issued*. What that
  workload may then *do* inside a service is that service's decision, made against its own resources.
  Conflating the two produces an STS that must know every domain model in the platform.
- **You have one service.** The exchange has real cost — a new hard dependency, a new operational
  surface, a new bootstrap problem. Below some size, platform-native identity used directly is the
  right answer and this is premature.
- **You cannot attest workloads.** If nothing in your runtime can vouch for what a process is, the STS
  has no trust root and will end up bootstrapped from a shared secret — which is the thing it exists
  to remove. Fix attestation first.

## Scope and non-goals

In scope: the identity namespace; enrolling attestors and verifying platform attestations; the token
exchange endpoint and its policy evaluation; delegation via actor tokens; the issued token format;
signing key custody and rotation; how relying parties verify; revocation; availability and bootstrap
constraints; audit.

Out of scope: end-user authentication and session management; authorisation policy inside a service;
secret storage (the STS issues identity, it is not a secrets manager); service mesh transport
security; and the specific runtime mechanism by which a platform attests a workload, which varies and
is handled through the pluggable attestor interface specified here.

## Vocabulary

These terms are used exactly as defined here throughout. Where a term also appears in
[ZBP-1](/zbp/1-dpop-bound-requests/), it means the same thing.

- **Workload** — a running instance of a service. The thing that holds a token.
- **Trust domain** — the root of an identity namespace. One STS serves exactly one trust domain.
- **Workload identity** — the stable, structured name of a workload, e.g.
  `spiffe://example.internal/ns/payments/sa/transfers-api`.
- **Attestation** — evidence, produced by the platform rather than by the workload, of what a workload
  is. A projected service account token, a signed instance identity document, a client certificate.
- **Attestor** — an enrolled trust root that produces attestations, plus the configuration describing
  how to verify them and how to map their claims onto a workload identity.
- **Subject token** — the attestation presented to the exchange endpoint, naming who is asking.
- **Actor token** — an optional second token naming a party acting *on behalf of* the subject.
- **Issued token** — the short-lived access token the STS returns.
- **Audience** — the single service an issued token is valid for, named by a stable URI.
- **Resource server (RS)** — a service that accepts an issued token. Same meaning as in ZBP-1.
- **Control plane** — where attestors, audiences, and policy are authored. **Data plane** — the
  issuance path that serves requests.

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as in RFC 2119 and
RFC 8174.

## Architecture

```mermaid
flowchart TB
    CP["`**Control plane** (authoring)
attestors · audiences · policy · revocations`"]
    STS["`**STS**
verify → map → policy → issue`"]
    KMS[("`KMS / HSM
sign only
key never leaves`")]
    PLAT["`**Platform** (runtime)`"]
    WL["`**Workload**
holds no secret`"]
    RS["`**Resource server**
verifies locally`"]

    CP -. "push: versioned, cached<br/>NEVER called on the hot path" .-> STS
    PLAT -- "1. platform attestation" --> STS
    PLAT -. attests .-> WL
    STS <--> KMS
    STS -- "2. at+jwt · one audience<br/>≤5 min · cnf.jkt" --> WL
    WL -- "3. Authorization: DPoP token<br/>+ DPoP proof" --> RS
    STS -. "JWKS (public, cached)" .-> RS
```

Four properties are load-bearing, and each one is a requirement below rather than a nice-to-have:

1. **The bootstrap has no secret in it.** Trust starts at the platform's own attestation of the
   workload. If step 1 needs a credential the workload had to be given, the whole structure inherits
   that credential's weaknesses.
2. **The control plane is not on the hot path.** Issuance runs from pushed, versioned, cached
   configuration. A control-plane outage must not become a platform-wide authentication outage.
3. **Verification is a computation, not a call.** Resource servers verify signatures against a cached
   JWKS. If every request to every service required a round trip to the STS, the STS would be a
   synchronous dependency of every request in the platform.
4. **The signing key is never in the process.** The STS asks a KMS or HSM to sign. A compromised STS
   instance can mint tokens while it is compromised; it cannot walk away with the ability to mint them
   forever.

## Normative requirements

### Identity and naming

- **ZBP-2-R1** Every principal MUST have a stable, structured, machine-parseable identity. Shared,
  generic, or unnamed identities — `system`, `service`, `default` — MUST NOT be issuable.
- **ZBP-2-R2** The identity namespace MUST be a hierarchical URI encoding the trust domain and the
  workload's path within it, and SHOULD follow the [SPIFFE ID](ref:spiffe-id) form
  `spiffe://<trust-domain>/<path>` so that existing tooling interoperates.
- **ZBP-2-R3** An identity MUST NOT be reused for a different workload after decommissioning, for at
  least the retention period of the audit log. Names in an audit trail must mean one thing forever.
- **ZBP-2-R4** Human identities and workload identities MUST be distinguishable from the identity
  string alone, without a lookup.
- **ZBP-2-R5** A workload MUST NOT present a human's credential as its own. Acting on behalf of a
  human MUST use the delegation mechanism below, so that both the actor and the principal appear on
  every resulting action.

### Attestation and trust roots

- **ZBP-2-R6** The STS MUST accept subject tokens only from explicitly enrolled attestors. Each
  attestor's configuration MUST specify: the expected issuer, the key source, the permitted signing
  algorithms, the required audience, and the mapping from attestation claims to workload identity.
- **ZBP-2-R7** The STS MUST verify a subject token's signature against the enrolled attestor's keys,
  MUST match the token's issuer exactly against the enrolled issuer, and MUST NOT discover keys or
  endpoints from any value carried in the token being verified.
- **ZBP-2-R8** The STS MUST require that a subject token's audience is the STS's own issuer
  identifier, and MUST reject a subject token minted for any other audience. Without this, any service
  that can obtain a platform token for its own purposes can replay it here.
- **ZBP-2-R9** The claims-to-identity mapping MUST be deterministic and total: an attestation either
  maps to exactly one workload identity or is rejected. Ambiguous or partial mappings MUST be
  rejected, not resolved by preference order.
- **ZBP-2-R10** The STS MUST derive the issued token's subject solely from attested claims. A value
  supplied by the caller outside the signed attestation MUST NOT influence the identity, scope, or
  lifetime of the issued token.
- **ZBP-2-R11** The STS MUST reject a subject token older than a bounded acceptance age, which MUST
  NOT exceed 10 minutes. Where the attestation carries a unique identifier, the STS MUST enforce
  single use of it for that period.
- **ZBP-2-R12** At least one enrolled attestor MUST have the runtime platform itself as its trust root
  — an instance identity document, a projected service account token, a workload certificate — so that
  a workload can obtain its first token without having been given any secret.
- **ZBP-2-R13** A long-lived static credential MUST NOT be enrolled as an attestor except as an
  individually approved, documented, and expiring exception with a named owner and a removal date.
- **ZBP-2-R14** Enrolling, modifying, or removing an attestor MUST be an audited control-plane change
  and MUST NOT require deploying the STS.

### Token exchange

- **ZBP-2-R15** The exchange endpoint MUST implement [RFC 8693](ref:rfc8693) with
  `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`, and MUST reject any other grant type.
- **ZBP-2-R16** A request MUST carry `subject_token` and `subject_token_type`, and MUST name the
  intended audience via `audience` or `resource`. A request that names no audience MUST be rejected;
  the STS MUST NOT substitute a default.
- **ZBP-2-R17** An issued token MUST be valid for exactly one audience. The STS MUST NOT issue a token
  carrying multiple audiences, and MUST reject a request naming more than one.
- **ZBP-2-R18** The STS MUST NOT issue authority the subject does not already hold. Exchange is
  downscoping only: the issued scope MUST be a subset of what policy grants the subject for that
  audience, and a request for anything outside it MUST be rejected rather than silently narrowed.
- **ZBP-2-R19** The STS MUST cap the issued token's lifetime server-side per audience. A
  caller-requested lifetime MAY shorten but MUST NOT extend it.
- **ZBP-2-R20** When an `actor_token` is present, the STS MUST verify it as it verifies a subject
  token, MUST confirm that policy — expressed as the subject's `may_act` — permits that actor, and
  MUST record the actor in the issued token's `act` claim. An unverified or unpermitted actor MUST
  cause rejection, never issuance without the `act` claim.
- **ZBP-2-R21** The STS MUST NOT require a long-lived client secret to call the exchange endpoint. The
  attestation is the authentication; a secret alongside it re-introduces exactly what this removes.

### Holder binding

- **ZBP-2-R22** When the exchange request carries a DPoP proof, the STS MUST bind the issued token to
  the proof key by setting `cnf.jkt`, as specified in ZBP-1-R10.
- **ZBP-2-R23** An audience MAY be configured to require bound tokens. For such an audience the STS
  MUST refuse to issue an unbound token, and the resource server MUST refuse to accept one.
- **ZBP-2-R24** The STS MUST NOT issue refresh tokens to workloads. A workload re-attests; a refresh
  token held by a workload is a long-lived credential wearing a different name.

### Issued token format

- **ZBP-2-R25** The issued token MUST be a JWT with `typ` set to `at+jwt` per
  [RFC 9068](ref:rfc9068), so it cannot be confused with an identity token or an attestation.
- **ZBP-2-R26** The token MUST carry `iss`, `sub`, `aud`, `iat`, `nbf`, `exp`, `jti`, and `client_id`.
  `jti` MUST be unique per issuance.
- **ZBP-2-R27** `aud` MUST be a single string naming one audience, and `iss` MUST be the STS's stable
  issuer identifier.
- **ZBP-2-R28** The token's lifetime MUST NOT exceed 15 minutes and SHOULD NOT exceed 5. Lifetime is
  the primary revocation mechanism, and every minute of it is a minute a leaked token stays useful.
- **ZBP-2-R29** The token MUST NOT carry secrets, credentials, or personal data beyond what the
  audience needs to authorise the call. A token is a bearer-visible structure logged in many places.
- **ZBP-2-R30** The signing algorithm MUST be asymmetric, MUST be listed in the STS's published
  metadata, and MUST NOT be `none` or MAC-based. The header MUST carry `kid`.
- **ZBP-2-R31** Granted authority MUST be enumerated explicitly. Wildcard or open-ended scopes MUST
  NOT be issued.

### Signing keys

- **ZBP-2-R32** Private signing keys MUST be generated in, and MUST NOT be exportable from, a KMS or
  HSM. The key material MUST NOT exist in application memory, configuration, environment, or backups.
- **ZBP-2-R33** `kid` MUST be the [RFC 7638](ref:rfc7638) thumbprint of the public key, so any party
  can recompute it and no separate name registry is needed.
- **ZBP-2-R34** The STS MUST publish its public keys as a JWKS at a stable URL containing only public
  key parameters, and MUST publish [RFC 8414](ref:rfc8414) metadata describing its issuer, endpoints,
  and supported algorithms.
- **ZBP-2-R35** Key rotation MUST overlap. A new key MUST be published in the JWKS and given at least
  the maximum relying-party cache TTL to propagate *before* it signs anything, and a retired key MUST
  remain published for at least the maximum token lifetime after its last use.
- **ZBP-2-R36** The STS MUST support rotating and emergency-removing a signing key without a code
  deploy, and the emergency path MUST NOT depend on the STS itself being able to issue tokens.
- **ZBP-2-R37** A relying party MUST NOT fetch the JWKS per request. It MUST cache it with a bounded
  TTL, and MUST rate-limit refetches triggered by an unknown `kid` so that a forged `kid` cannot be
  turned into a request amplifier against the STS.

### Verification by relying parties

- **ZBP-2-R38** A resource server MUST verify tokens locally against the cached JWKS. It MUST NOT call
  the STS, an introspection endpoint, or any other service on the per-request verification path.
- **ZBP-2-R39** The resource server MUST verify, before using any claim: that `typ` is `at+jwt`; that
  `alg` is in its own configured permitted set; that `kid` resolves in the cached JWKS; that the
  signature verifies; that `iss` exactly matches the configured issuer; and that the token is within
  `nbf`/`exp`.
- **ZBP-2-R40** The resource server MUST reject a token whose `aud` is not exactly its own audience
  identifier. Substring, prefix, or suffix matching MUST NOT be used.
- **ZBP-2-R41** The resource server MUST treat absent authority as denial. A missing or unrecognised
  scope MUST NOT fall back to a default grant.
- **ZBP-2-R42** When a token carries `cnf.jkt`, the resource server MUST perform the full ZBP-1
  validation, including the thumbprint comparison in ZBP-1-R20, and MUST NOT accept the token under
  the `Bearer` scheme (ZBP-1-R12).

### Revocation

- **ZBP-2-R43** Short lifetime MUST be the primary revocation mechanism. A revocation list MUST NOT be
  the only thing standing between a leaked token and its use.
- **ZBP-2-R44** The STS MUST support revoking by workload identity, by `jti`, and by signing key. The
  system MUST store **revocations**, not issuances: the steady-state cost of the mechanism must scale
  with the rare event, not the common one.
- **ZBP-2-R45** A revocation entry MUST be retained at least as long as the maximum lifetime of what
  it revokes, and the maximum propagation delay to relying parties MUST be bounded, measured, and
  documented as part of the security model.

### Availability, bootstrap, and operations

- **ZBP-2-R46** The issuance path MUST NOT call the control plane. It MUST run from pushed, versioned
  configuration and MUST continue issuing on last-known-good configuration while the control plane is
  unavailable.
- **ZBP-2-R47** The STS MUST NOT depend on itself, directly or transitively, to start or to serve. No
  component on the issuance path may require a token this STS issues.
- **ZBP-2-R48** Incident response tooling — break-glass access, kill switches, deploy and rollback —
  MUST NOT depend solely on the STS being available, and the alternative path MUST be exercised on a
  schedule rather than discovered during an incident.
- **ZBP-2-R49** Issuance MUST fail closed. Any error, timeout, or unavailable dependency in
  verification or policy evaluation MUST result in refusal, never in a token with default or reduced
  claims.
- **ZBP-2-R50** Issuance MUST NOT require a write to a transactional store on the hot path. The STS
  must remain available when its supporting stores are not, which is only possible if issuance is a
  computation over cached configuration plus a signing operation.

### Audit

- **ZBP-2-R51** Every issuance and every refusal MUST be recorded with: the subject, the actor if any,
  the audience, the granted scope, the attestor, the `jti`, the `cnf.jkt` if bound, the decision, and
  the version of the policy that produced it.
- **ZBP-2-R52** Audit records MUST NOT contain issued tokens, subject tokens, or key material, and
  MUST be written to a store the STS itself cannot silently rewrite.

## Wire format

### Exchange request

```http
POST /oauth2/token HTTP/1.1
Host: sts.example.internal
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&subject_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IlZHLXpzbEpX...
&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt
&audience=https%3A%2F%2Fapi.example.com
&scope=transfers%3Awrite+ledger%3Aread
```

### Exchange response

```json
{
  "access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6InJiR2JpSjc1...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "DPoP",
  "expires_in": 300,
  "scope": "transfers:write ledger:read"
}
```

`token_type` is `DPoP` when the token is bound and `Bearer` when it is not. It is not decorative: it
tells the caller which `Authorization` scheme to use, and ZBP-1-R12 makes presenting a bound token
under the wrong one a rejection.

### Issued token claims

```json
{
  "iss": "https://sts.example.internal",
  "sub": "spiffe://example.internal/ns/payments/sa/transfers-api",
  "aud": "https://api.example.com",
  "iat": 1782000000,
  "nbf": 1782000000,
  "exp": 1782000300,
  "jti": "01JZQ8F2K7N4V6XB3M9YRTCDEW",
  "client_id": "spiffe://example.internal/ns/payments/sa/transfers-api",
  "scope": "transfers:write ledger:read",
  "cnf": { "jkt": "bXZC-fhZ_fhHiQR84HV54gv2HSzWKOgwcwnhJOx8bg4" }
}
```

With delegation, `sub` remains the party being acted for and `act` names the actor:

```json
{
  "sub": "spiffe://example.internal/ns/payments/sa/transfers-api",
  "client_id": "spiffe://example.internal/ns/batch/sa/settlement-runner",
  "act": { "sub": "spiffe://example.internal/ns/batch/sa/settlement-runner" },
  "scope": "ledger:read"
}
```

The nesting direction matters and is routinely inverted by implementations: `sub` is *who the action
is for*, `act` is *who is doing it*. An audit log built from these must be able to render both, or
delegation becomes indistinguishable from impersonation.

### JWKS

```json
{
  "keys": [
    {
      "crv": "P-256",
      "kty": "EC",
      "x": "ee40l9RwHHYggPkjfX2DhtWIQvaEpWZCzb9o_6JGC3Y",
      "y": "VgOc0ZysEjvOss6tMyX8tbFgRud1fK4cY6UBnGuE0tE",
      "use": "sig",
      "alg": "ES256",
      "kid": "rbGbiJ75h9F1f-tTp7g-GrxlqEikNSbwwjrivPgGy7A"
    }
  ]
}
```

The `kid` is the RFC 7638 thumbprint of `{crv, kty, x, y}` (ZBP-2-R33), so a relying party can verify
that a key's advertised identifier actually belongs to that key rather than trusting the label.

### Metadata

```json
{
  "issuer": "https://sts.example.internal",
  "token_endpoint": "https://sts.example.internal/oauth2/token",
  "jwks_uri": "https://sts.example.internal/.well-known/jwks.json",
  "grant_types_supported": ["urn:ietf:params:oauth:grant-type:token-exchange"],
  "token_endpoint_auth_methods_supported": ["none"],
  "dpop_signing_alg_values_supported": ["ES256", "ES384", "EdDSA"],
  "id_token_signing_alg_values_supported": ["ES256"]
}
```

`token_endpoint_auth_methods_supported: ["none"]` is deliberate and correct here (ZBP-2-R21): the
subject token *is* the authentication.

### Errors

```http
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "invalid_target",
  "error_description": "audience not permitted for this subject"
}
```

Use `invalid_request` for a malformed exchange, `invalid_grant` for a subject or actor token that
fails verification, `invalid_target` for an audience or scope the subject may not have, and
`invalid_client` where the caller is not an enrolled principal at all. Error descriptions MUST NOT
reveal which policy rule matched or what scope *would* have been permitted.

## Exchange algorithm

The STS MUST evaluate in this order. As in ZBP-1, the order is not cosmetic: nothing may be trusted
before it is verified, and nothing expensive runs before the cheap rejections.

| # | Step | Requirement | Failure |
|---|---|---|---|
| 1 | `grant_type` is token-exchange; required parameters present | ZBP-2-R15, ZBP-2-R16 | `invalid_request` |
| 2 | Exactly one audience named, and it is a known audience | ZBP-2-R17 | `invalid_target` |
| 3 | Subject token's issuer matches an enrolled attestor | ZBP-2-R6, ZBP-2-R7 | `invalid_grant` |
| 4 | `alg` permitted for that attestor; `kid` resolves in its cached keys | ZBP-2-R6 | `invalid_grant` |
| 5 | Subject token signature verifies | ZBP-2-R7 | `invalid_grant` |
| 6 | Subject token audience is this STS's issuer identifier | ZBP-2-R8 | `invalid_grant` |
| 7 | Subject token within lifetime and acceptance age; identifier unused | ZBP-2-R11 | `invalid_grant` |
| 8 | Attested claims map to exactly one workload identity | ZBP-2-R9, ZBP-2-R10 | `invalid_grant` |
| 9 | Actor token verified (steps 3–7) and permitted by `may_act`, if present | ZBP-2-R20 | `invalid_grant` |
| 10 | Policy grants this subject that audience | ZBP-2-R18 | `invalid_target` |
| 11 | Requested scope is a subset of the grant | ZBP-2-R18 | `invalid_target` |
| 12 | DPoP proof validated and `jkt` extracted, if present or required | ZBP-2-R22, ZBP-2-R23 | `invalid_dpop_proof` |
| 13 | Lifetime resolved: min(requested, audience cap) | ZBP-2-R19 | — |
| 14 | Claims assembled; token signed by KMS | ZBP-2-R25…R33 | fail closed |
| 15 | Audit record written | ZBP-2-R51 | fail closed |

Step 11 is where an STS quietly becomes dangerous. The temptation is to intersect the requested scope
with the grant and issue whatever remains, because it feels safe — the result is never broader than
the grant. But it converts a caller bug into a silent partial success: the workload asked for
`transfers:write`, holds only `ledger:read`, receives a token it believes carries both, and fails much
later in a place with no connection to the cause. Reject instead (ZBP-2-R18).

Step 15 before returning, not after. An issuance that is not in the audit log did not happen as far as
every subsequent investigation is concerned.

## Threat model and failure modes

| Threat | Result |
|---|---|
| Issued token leaks (logs, crash dump, proxy) | Useless after ≤5 min (ZBP-2-R28); useless immediately to anyone without the holder key when bound (ZBP-2-R22, ZBP-1-R20) |
| Attacker replays a platform attestation at the STS | Rejected: attestations are audience-bound to the STS and single-use within a bounded age (ZBP-2-R8, ZBP-2-R11) |
| Service A obtains a platform token and replays it to impersonate service B | Rejected: identity comes from attested claims only, and the mapping is total (ZBP-2-R9, ZBP-2-R10) |
| Caller asks for a broader scope than it holds | Rejected, not narrowed (ZBP-2-R18) |
| Caller asks for a token for an audience it may not call | Rejected (ZBP-2-R18); the token is useless elsewhere anyway (ZBP-2-R17, ZBP-2-R40) |
| Token minted for one service replayed at another | Rejected: single audience, exact match (ZBP-2-R27, ZBP-2-R40) |
| Forged token with `alg: none` or a MAC | Rejected: permitted algorithms come from RS config (ZBP-2-R30, ZBP-2-R39) |
| Forged token signed by an attacker's key, claiming a real `kid` | Rejected: `kid` resolves to the *published* key, and the signature is verified against it (ZBP-2-R39) |
| STS instance compromised | Attacker mints tokens **while** they hold the instance; they do not obtain the signing key (ZBP-2-R32). Detection is the audit log; response is key rotation (ZBP-2-R36) |
| KMS signing key compromised | **Total.** Every audience must be re-keyed. This is the residual risk the design concentrates in one heavily-audited place |
| Control plane down | Issuance continues on last-known-good config (ZBP-2-R46) |
| STS down | **No new tokens platform-wide.** Existing tokens work until expiry — which is why short lifetimes and STS availability pull in opposite directions, and why this is a deliberate trade rather than an oversight |

The last row deserves the honesty: this design deliberately makes one service critical to the whole
platform. That is the cost of the property it buys, and it must be priced in — the STS gets the
availability engineering of a tier-zero dependency, or it should not be built. Three consequences
follow directly, and they are the requirements most often skipped: no self-dependency (ZBP-2-R47),
incident tooling that survives its absence (ZBP-2-R48), and no transactional store on the hot path
(ZBP-2-R50).

The operational failure modes:

- **Rotation without overlap** (ZBP-2-R35) is the classic self-inflicted outage: a new key signs
  before relying parties have cached it, and every service in the platform rejects every token at
  once. The window is bounded by the *slowest* cache in the fleet, not the configured TTL.
- **JWKS refetch storms.** An unknown `kid` triggering an immediate refetch turns one forged token
  into a request amplifier, and a rotation into a thundering herd. Rate-limit it (ZBP-2-R37).
- **Clock skew** shrinks the usable portion of an already-short lifetime. Issue with `nbf` equal to
  `iat`, not in the future, and keep the platform's clocks disciplined.
- **Audience proliferation.** Every new service is a new audience and a new set of grants. Without a
  scheme for authoring these, policy becomes the bottleneck for shipping anything, and the pressure to
  add a wildcard becomes irresistible (ZBP-2-R31).

## Anti-patterns

- **The STS that verifies its own tokens for everyone.** An introspection call per request makes the
  STS a synchronous dependency of every request in the platform, and its latency everyone's latency.
  Verify by computation (ZBP-2-R38).
- **The bootstrap secret.** A "just for onboarding" static credential that lets a workload get its
  first token. It becomes permanent, it gets copied, and it is now the real root of trust
  (ZBP-2-R12, ZBP-2-R13).
- **The wildcard audience.** One token that works everywhere is a bearer credential for the entire
  platform. It defeats ZBP-2-R17 and every containment property that depends on it.
- **Intersecting scope instead of rejecting.** Feels conservative, produces silent partial grants that
  fail far from their cause (ZBP-2-R18).
- **Long lifetimes to reduce STS load.** Turns the primary revocation mechanism off to save a cache
  miss. If issuance load is the problem, cache the token in the workload for its actual lifetime and
  fix the load, not the security parameter (ZBP-2-R28).
- **Refresh tokens for workloads.** A long-lived credential with a friendlier name. The workload can
  re-attest; that is the entire point (ZBP-2-R24).
- **`system` in the audit log.** If the actor is `system`, attribution is already gone, and revocation
  cannot be surgical because there is nothing specific to revoke (ZBP-2-R1).
- **Copying a user's token into a background job.** That is impersonation, and it is invisible after
  the fact. Delegation with `act` exists so the job is attributable to itself *and* to whom it acts
  for (ZBP-2-R5, ZBP-2-R20).
- **Signing in-process with a key from the secret store.** Removes the one property that survives
  instance compromise (ZBP-2-R32).

## Test vectors

Two published test keys. **Neither MUST be used for anything real.**

The STS signing key (private parameter included so a harness can regenerate vectors):

```json
{
  "kty": "EC", "crv": "P-256",
  "x": "ee40l9RwHHYggPkjfX2DhtWIQvaEpWZCzb9o_6JGC3Y",
  "y": "VgOc0ZysEjvOss6tMyX8tbFgRud1fK4cY6UBnGuE0tE",
  "d": "ianuunTXUNmDgK7Agw_0CWsoTLKVFd2ZaL6-e_2oZ2U"
}
```

The attestor (platform OIDC) public key:

```json
{
  "kty": "EC", "crv": "P-256",
  "x": "bPjKKaIctIfTElLsmKTvRMXvHv2EDsfEXJazoung9GU",
  "y": "ZegVSobzxgweE080GqjFuqzwWmDN0qoaTOg3HRIkZ4E",
  "use": "sig", "alg": "ES256",
  "kid": "VG-zslJWeiCVbc1DfMEOLSgVx3BqNoniXZIN9Ne8xzw"
}
```

Fixed parameters:

| Parameter | Value |
|---|---|
| STS issuer | `https://sts.example.internal` |
| STS `kid` | `rbGbiJ75h9F1f-tTp7g-GrxlqEikNSbwwjrivPgGy7A` |
| Audience under test | `https://api.example.com` |
| Workload identity | `spiffe://example.internal/ns/payments/sa/transfers-api` |
| Delegating actor | `spiffe://example.internal/ns/batch/sa/settlement-runner` |
| Holder key `jkt` | `bXZC-fhZ_fhHiQR84HV54gv2HSzWKOgwcwnhJOx8bg4` (ZBP-1's test key) |
| Reference time | `1782000000` (2026-06-21T00:00:00Z) |

The reference time is the same as ZBP-1's, and the `cnf.jkt` is ZBP-1's test key, so a harness can
exercise both blueprints together — see T7. Freeze the clock to the reference time, or T1, T2, and T4
will fail on expiry rather than on the check under test.

### S1 — subject token: a valid platform attestation (expect: accept at exchange step 5–8)

Issued by the attestor, audience-bound to the STS, mapping to the workload identity.

```
eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IlZHLXpzbEpXZWlDVmJjMURmTUVPTFNnVngzQnFOb25pWFpJTjlOZTh4encifQ.eyJpc3MiOiJodHRwczovL29pZGMucGxhdGZvcm0uZXhhbXBsZS5pbnRlcm5hbCIsInN1YiI6InNwaWZmZTovL2V4YW1wbGUuaW50ZXJuYWwvbnMvcGF5bWVudHMvc2EvdHJhbnNmZXJzLWFwaSIsImF1ZCI6Imh0dHBzOi8vc3RzLmV4YW1wbGUuaW50ZXJuYWwiLCJpYXQiOjE3ODIwMDAwMDAsImV4cCI6MTc4MjAwMDYwMCwianRpIjoiYXR0LTZmMWM5ZDAyIiwibmFtZXNwYWNlIjoicGF5bWVudHMiLCJzZXJ2aWNlX2FjY291bnQiOiJ0cmFuc2ZlcnMtYXBpIiwiaW5zdGFuY2UiOiJpLTBhMWIyYzNkNGU1ZiIsInJlZ2lvbiI6InVzLXdlc3QtMiJ9.P0fC9ciQY50eRHYdwnJFwWLKQ0YT5WonqdH9viITeTzNEEyBD3ThORauaZgGff-XhShNwQ0MonLRd3D2uTTViA
```

### T1 — issued token, valid and bound (expect: accept)

`at+jwt`, one audience, 5-minute lifetime, `cnf.jkt` naming ZBP-1's holder key.

```
eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6InJiR2JpSjc1aDlGMWYtdFRwN2ctR3J4bHFFaWtOU2J3d2pyaXZQZ0d5N0EifQ.eyJpc3MiOiJodHRwczovL3N0cy5leGFtcGxlLmludGVybmFsIiwic3ViIjoic3BpZmZlOi8vZXhhbXBsZS5pbnRlcm5hbC9ucy9wYXltZW50cy9zYS90cmFuc2ZlcnMtYXBpIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3ODIwMDAwMDAsIm5iZiI6MTc4MjAwMDAwMCwiZXhwIjoxNzgyMDAwMzAwLCJqdGkiOiIwMUpaUThGMks3TjRWNlhCM005WVJUQ0RFVyIsImNsaWVudF9pZCI6InNwaWZmZTovL2V4YW1wbGUuaW50ZXJuYWwvbnMvcGF5bWVudHMvc2EvdHJhbnNmZXJzLWFwaSIsInNjb3BlIjoidHJhbnNmZXJzOndyaXRlIGxlZGdlcjpyZWFkIiwiY25mIjp7ImprdCI6ImJYWkMtZmhaX2ZoSGlRUjg0SFY1NGd2MkhTeldLT2d3Y3duaEpPeDhiZzQifX0.mVqcihHHM-i3mYmSDj2Pou8Bsfkmc6WRbf0m_Tf64_rDXZ2bvaGeWtGJrBvuTcTCIW5vSSIw7KCDeINZgkYowA
```

### T2 — delegation (expect: accept, with both parties attributable)

The settlement runner acting for the transfers service: `sub` is the principal, `act.sub` the actor,
`client_id` the actor. A conforming audit record for this token names **both**.

```
eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6InJiR2JpSjc1aDlGMWYtdFRwN2ctR3J4bHFFaWtOU2J3d2pyaXZQZ0d5N0EifQ.eyJpc3MiOiJodHRwczovL3N0cy5leGFtcGxlLmludGVybmFsIiwic3ViIjoic3BpZmZlOi8vZXhhbXBsZS5pbnRlcm5hbC9ucy9wYXltZW50cy9zYS90cmFuc2ZlcnMtYXBpIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3ODIwMDAwMDAsIm5iZiI6MTc4MjAwMDAwMCwiZXhwIjoxNzgyMDAwMzAwLCJqdGkiOiIwMUpaUThGNUIyUDBROFpDMUg3S1ROTURYQSIsImNsaWVudF9pZCI6InNwaWZmZTovL2V4YW1wbGUuaW50ZXJuYWwvbnMvYmF0Y2gvc2Evc2V0dGxlbWVudC1ydW5uZXIiLCJzY29wZSI6ImxlZGdlcjpyZWFkIiwiYWN0Ijp7InN1YiI6InNwaWZmZTovL2V4YW1wbGUuaW50ZXJuYWwvbnMvYmF0Y2gvc2Evc2V0dGxlbWVudC1ydW5uZXIifSwiY25mIjp7ImprdCI6ImJYWkMtZmhaX2ZoSGlRUjg0SFY1NGd2MkhTeldLT2d3Y3duaEpPeDhiZzQifX0.pvzLmVdzikVC0EB86J9H3tkOsO7Rzn7x0tRtA_XvKJeeM1sKLJKHoLOnZ0jo9Lt7wKHvBfEjg-W7OHioDsBb7A
```

### T3 — expired (expect: reject, `nbf`/`exp` check)

Correctly signed, one hour before the reference time. Tests ZBP-2-R39.

```
eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6InJiR2JpSjc1aDlGMWYtdFRwN2ctR3J4bHFFaWtOU2J3d2pyaXZQZ0d5N0EifQ.eyJpc3MiOiJodHRwczovL3N0cy5leGFtcGxlLmludGVybmFsIiwic3ViIjoic3BpZmZlOi8vZXhhbXBsZS5pbnRlcm5hbC9ucy9wYXltZW50cy9zYS90cmFuc2ZlcnMtYXBpIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3ODE5OTY0MDAsIm5iZiI6MTc4MTk5NjQwMCwiZXhwIjoxNzgxOTk2NzAwLCJqdGkiOiIwMUpaUThGQTNDNlIyVDRXRDVOOFZHWEtRWSIsImNsaWVudF9pZCI6InNwaWZmZTovL2V4YW1wbGUuaW50ZXJuYWwvbnMvcGF5bWVudHMvc2EvdHJhbnNmZXJzLWFwaSIsInNjb3BlIjoidHJhbnNmZXJzOndyaXRlIiwiY25mIjp7ImprdCI6ImJYWkMtZmhaX2ZoSGlRUjg0SFY1NGd2MkhTeldLT2d3Y3duaEpPeDhiZzQifX0.9R9i28k9Z74QwWyaAPtYMz0K3NpMYXbuoPHVENe_g_2ljao3do-ZqsYItn3QXJgMlNarVDW6vUWHDDeA1ET4kA
```

### T4 — wrong audience (expect: reject, exact `aud` match)

A perfectly valid, unexpired, correctly signed token issued for `https://reports.example.com`,
presented to `https://api.example.com`. Tests ZBP-2-R40, and specifically catches the substring or
prefix matching that makes single-audience tokens meaningless.

```
eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6InJiR2JpSjc1aDlGMWYtdFRwN2ctR3J4bHFFaWtOU2J3d2pyaXZQZ0d5N0EifQ.eyJpc3MiOiJodHRwczovL3N0cy5leGFtcGxlLmludGVybmFsIiwic3ViIjoic3BpZmZlOi8vZXhhbXBsZS5pbnRlcm5hbC9ucy9wYXltZW50cy9zYS90cmFuc2ZlcnMtYXBpIiwiYXVkIjoiaHR0cHM6Ly9yZXBvcnRzLmV4YW1wbGUuY29tIiwiaWF0IjoxNzgyMDAwMDAwLCJuYmYiOjE3ODIwMDAwMDAsImV4cCI6MTc4MjAwMDMwMCwianRpIjoiMDFKWlE4RkRYMU01UzlZRTdQMlpIQldOVFIiLCJjbGllbnRfaWQiOiJzcGlmZmU6Ly9leGFtcGxlLmludGVybmFsL25zL3BheW1lbnRzL3NhL3RyYW5zZmVycy1hcGkiLCJzY29wZSI6InRyYW5zZmVyczp3cml0ZSIsImNuZiI6eyJqa3QiOiJiWFpDLWZoWl9maEhpUVI4NEhWNTRndjJIU3pXS09nd2N3bmhKT3g4Ymc0In19.w_ALaIIv13t1IQwGOkyiYfDFASAiax9q3ru8m8AO0Yt2pkuM3whm8PEtOOsIizh7sBeIo0Iyu0UcVn1RVt9tIg
```

### T5 — unsigned (expect: reject at the algorithm check)

`alg: none`, empty signature, and a `scope` of `transfers:write admin:*` for good measure. Tests
ZBP-2-R30 and ZBP-2-R39. Any library configured to accept the algorithm named in the header will
accept this.

```
eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJub25lIiwia2lkIjoicmJHYmlKNzVoOUYxZi10VHA3Zy1HcnhscUVpa05TYnd3anJpdlBnR3k3QSJ9.eyJpc3MiOiJodHRwczovL3N0cy5leGFtcGxlLmludGVybmFsIiwic3ViIjoic3BpZmZlOi8vZXhhbXBsZS5pbnRlcm5hbC9ucy9wYXltZW50cy9zYS90cmFuc2ZlcnMtYXBpIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3ODIwMDAwMDAsIm5iZiI6MTc4MjAwMDAwMCwiZXhwIjoxNzgyMDAwMzAwLCJqdGkiOiIwMUpaUThGTjdIMkU0RzZKQThSME1EWVdTQyIsImNsaWVudF9pZCI6InNwaWZmZTovL2V4YW1wbGUuaW50ZXJuYWwvbnMvcGF5bWVudHMvc2EvdHJhbnNmZXJzLWFwaSIsInNjb3BlIjoidHJhbnNmZXJzOndyaXRlIGFkbWluOioifQ.
```

### T6 — forged, signed by an unpublished key (expect: reject at signature verification)

**The vector that matters.** Well-formed, unexpired, correct audience, correct issuer, and a `kid`
header claiming the real STS key — but signed by a key whose true thumbprint is
`_RfLPvhnhud9oi1IMVXawb0UDZjKlIbmgRCAjxZQWoc`, which is not in the JWKS. It also asks for
`transfers:write admin:*`, which is what a forgery is for.

An implementation that reads `kid`, fails to find it, and refetches without limit turns this into an
amplification vector (ZBP-2-R37). An implementation that decodes claims before verifying the signature
grants `admin:*`. Only verifying against the *published* key stops it.

```
eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6InJiR2JpSjc1aDlGMWYtdFRwN2ctR3J4bHFFaWtOU2J3d2pyaXZQZ0d5N0EifQ.eyJpc3MiOiJodHRwczovL3N0cy5leGFtcGxlLmludGVybmFsIiwic3ViIjoic3BpZmZlOi8vZXhhbXBsZS5pbnRlcm5hbC9ucy9wYXltZW50cy9zYS90cmFuc2ZlcnMtYXBpIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3ODIwMDAwMDAsIm5iZiI6MTc4MjAwMDAwMCwiZXhwIjoxNzgyMDAwMzAwLCJqdGkiOiIwMUpaUThGSjBWOFQzVzVaRjZRMUtDWE1QQiIsImNsaWVudF9pZCI6InNwaWZmZTovL2V4YW1wbGUuaW50ZXJuYWwvbnMvcGF5bWVudHMvc2EvdHJhbnNmZXJzLWFwaSIsInNjb3BlIjoidHJhbnNmZXJzOndyaXRlIGFkbWluOioiLCJjbmYiOnsiamt0IjoiYlhaQy1maFpfZmhIaVFSODRIVjU0Z3YySFN6V0tPZ3djd25oSk94OGJnNCJ9fQ.R2CYMJgxQBeq1ogEm9q-mjI04wf6RH22p1Pgxz6rzsDhTi1krci6KAMO2gmmepWanGl0nycAkexUSLjh-7TD7g
```

### T7 — composed with ZBP-1

The only vectors that exercise both blueprints at once, and the ones that prove the composition is
real rather than nominal. All three present **T1** to `POST https://api.example.com/v1/transfers` with
`Authorization: DPoP <T1>`, varying only the proof.

**T7a — proof over T1 from the holder key (expect: accept).** Signed by ZBP-1's test key, whose
thumbprint is what T1's `cnf.jkt` names, with `ath` over T1's bytes.

```
eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7ImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiN2FSN0ZHaDVybXJ5dE9xdjhTcUVaTGl1QWJNbVNGTktqNEtUTm9FU3Y4byIsInkiOiJqZUhSWlZOSmVEQTJBMFVpbWNsb3hGLWNTOGlWeE00TGt3ZEh1MDc4aGM0In19.eyJqdGkiOiI4YzFkNWUyYS05ZjA0LTRiNzYtYTNlOC01ZDJmN2MwOTFiNjQiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vdjEvdHJhbnNmZXJzIiwiaWF0IjoxNzgyMDAwMDAwLCJhdGgiOiJLc2tQbUVMempKNmt3anM3M3ZoUkEwTkRRMjRtZFI0N3hxMmM4Z0NKR2pRIiwibm9uY2UiOiJleUo3U196Ry5leUpIMC1aLkhYNHctN3YifQ.DxfIY1qq-GR1D7n6hogT6dYoE_9cLV4QBS3PS0JbY0Lm_xpBXRz1Q_Okm8V_K-UW1M6UfTMAT9TMZRzu0fpsoQ
```

**T7b — proof over T1 from a different key (expect: reject, ZBP-1-R20).** Correctly signed, correct
`ath`, correct method and URI — but the proof key's thumbprint is
`cGDwql41NuyM0DJRIyqkoVeGKQzRKq6DQ4ZqMVFOc5Q`, and T1's `cnf.jkt` names
`bXZC-fhZ_fhHiQR84HV54gv2HSzWKOgwcwnhJOx8bg4`. This is precisely what an attacker holding a stolen T1
can produce: everything except the key. It MUST be rejected on the thumbprint comparison.

```
eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7ImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiQzVpdnlzZ19JcUI3ZkFIc0l3VGk5bjliZUtGck1KSXdBS2U1UHlsOUxNMCIsInkiOiJJZXVWM2Job2tfcUw2QXlaRnlPRm1PREx4SFBDcVczNVFKLWxYLVVNNENzIn19.eyJqdGkiOiI5ZDJlNmYzYi0wYTE1LTRjODctYjRmOS02ZTNhOGQxMDJjNzUiLCJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vdjEvdHJhbnNmZXJzIiwiaWF0IjoxNzgyMDAwMDAwLCJhdGgiOiJLc2tQbUVMempKNmt3anM3M3ZoUkEwTkRRMjRtZFI0N3hxMmM4Z0NKR2pRIiwibm9uY2UiOiJleUo3U196Ry5leUpIMC1aLkhYNHctN3YifQ.ZNDI13HFO0UU8M_rKD_O5i7D4pLleh7V1uuvxXxuQt-kTY32beHNjbhgDWZDqnpPSSQF0qaXp_0HeL3vdXqaDw
```

**T7c — ZBP-1's V1 proof reused verbatim with T1 (expect: reject, ZBP-1-R19).** V1 is a perfectly
valid proof, signed by the right key, within the window. It still MUST be rejected, because its `ath`
is the hash of ZBP-1's own example token (`rnV-hYdXXYDdGEAwKehz-fKzKOzbb-cRTN-GayQmObg`) and not of T1
(`KskPmELzjJ6kwjs73vhRA0NDQ24mdR47xq2c8gCJGjQ`).

This is worth asserting explicitly rather than treating as a technicality: it demonstrates that a
proof is bound to *one specific token*, so proofs cannot be harvested from one exchange and applied to
another. A harness that passes T7a and T7b but also accepts T7c has skipped ZBP-1-R19 and has left
proof-and-token pairs interchangeable.

## Rollout

Introducing this into a platform that runs on static credentials, without a flag day:

1. **Stand up the STS with one attestor and one audience.** Choose the runtime that attests most
   cleanly and a low-risk internal service. Nothing depends on it yet, so the availability engineering
   can lag by exactly one step.
2. **Dual-accept at the first resource server.** It accepts both its existing credential and an
   issued token, and reports which was used. This is where audience naming, clock discipline, and JWKS
   caching get shaken out at zero risk (ZBP-2-R37, ZBP-2-R39).
3. **Migrate callers, watching the split.** The telemetry from step 2 is the migration tracker: the
   remaining static-credential population is a measurable, closable list rather than a guess.
4. **Remove the static path at that audience.** Only then. The resource server rejects everything but
   issued tokens.
5. **Repeat per audience, highest-value first.** Each repetition is cheaper; the expensive parts —
   attestation, key custody, rotation, caching — are already built and exercised.
6. **Turn on binding where it earns its cost** (ZBP-2-R23, ZBP-1). Bound tokens per ZBP-1 are a
   separate, later increment: get identity working first, then constrain the holder.
7. **Only now delete the static credentials**, and rotate them as though they leaked, because for
   their whole lifetime they were spread across config, images, and CI logs.

Steps 2 and 3 hold the schedule. Budget for the observation period; treating it as a formality is how
step 4 becomes an outage.

## Conformance checklist

**Identity**

- [ ] Every principal has a structured identity; no generic or shared identities exist (ZBP-2-R1)
- [ ] The namespace is a hierarchical URI encoding trust domain and path (ZBP-2-R2)
- [ ] Identities are not recycled within the audit retention period (ZBP-2-R3)
- [ ] Human and workload identities are distinguishable from the string alone (ZBP-2-R4)
- [ ] Workloads never present human credentials; delegation is used instead (ZBP-2-R5)

**Attestation**

- [ ] Only enrolled attestors are accepted, each fully configured (ZBP-2-R6)
- [ ] Issuer pinned; no discovery from the token under verification (ZBP-2-R7)
- [ ] Subject token audience must be this STS (ZBP-2-R8)
- [ ] Claim mapping is deterministic and total (ZBP-2-R9)
- [ ] No caller-supplied value influences identity, scope, or lifetime (ZBP-2-R10)
- [ ] Attestation age bounded ≤10 min; identifier single-use (ZBP-2-R11)
- [ ] At least one platform-rooted attestor exists, so bootstrap needs no secret (ZBP-2-R12)
- [ ] Static-credential attestors exist only as approved, expiring exceptions (ZBP-2-R13)
- [ ] Attestor changes are audited control-plane changes, no deploy (ZBP-2-R14)

**Exchange**

- [ ] RFC 8693 token-exchange grant; all others rejected (ZBP-2-R15)
- [ ] Audience required; no default substituted (ZBP-2-R16)
- [ ] Exactly one audience per issued token (ZBP-2-R17)
- [ ] Downscope only; out-of-grant requests rejected, never narrowed (ZBP-2-R18)
- [ ] Server-side lifetime cap per audience (ZBP-2-R19)
- [ ] Actor token verified and permitted by `may_act`; `act` recorded (ZBP-2-R20)
- [ ] No long-lived client secret required (ZBP-2-R21)

**Binding**

- [ ] `cnf.jkt` set when a DPoP proof is presented (ZBP-2-R22)
- [ ] Binding-required audiences refuse unbound tokens at both ends (ZBP-2-R23)
- [ ] No refresh tokens issued to workloads (ZBP-2-R24)

**Token format**

- [ ] `typ` is `at+jwt` (ZBP-2-R25)
- [ ] All required claims present; `jti` unique (ZBP-2-R26)
- [ ] Single-string `aud`; stable `iss` (ZBP-2-R27)
- [ ] Lifetime ≤15 min, target ≤5 (ZBP-2-R28)
- [ ] No secrets or unnecessary personal data in claims (ZBP-2-R29)
- [ ] Asymmetric `alg`, published, with `kid` (ZBP-2-R30)
- [ ] No wildcard scopes issued (ZBP-2-R31)

**Keys**

- [ ] Signing keys non-exportable in KMS/HSM; absent from memory and config (ZBP-2-R32)
- [ ] `kid` is the RFC 7638 thumbprint (ZBP-2-R33)
- [ ] JWKS and RFC 8414 metadata published at stable URLs (ZBP-2-R34)
- [ ] Rotation overlaps: publish-before-use, retain-after-retire (ZBP-2-R35)
- [ ] Rotation and emergency removal need no deploy and no self-issued token (ZBP-2-R36)
- [ ] Relying parties cache JWKS and rate-limit unknown-`kid` refetches (ZBP-2-R37)

**Verification**

- [ ] Verification is local; no per-request call to the STS (ZBP-2-R38)
- [ ] Full check order applied before any claim is used (ZBP-2-R39)
- [ ] `aud` compared by exact match only (ZBP-2-R40)
- [ ] Absent authority denies (ZBP-2-R41)
- [ ] ZBP-1 validation applied to bound tokens, including the thumbprint check (ZBP-2-R42)

**Revocation, availability, audit**

- [ ] Short lifetime is the primary control (ZBP-2-R43)
- [ ] Revoke by identity, `jti`, and key; revocations stored, not issuances (ZBP-2-R44)
- [ ] Revocation retention ≥ max token lifetime; propagation bounded and documented (ZBP-2-R45)
- [ ] Issuance never calls the control plane; last-known-good config serves (ZBP-2-R46)
- [ ] No self-dependency anywhere on the issuance path (ZBP-2-R47)
- [ ] Incident tooling survives an STS outage, and that path is exercised on a schedule (ZBP-2-R48)
- [ ] Issuance fails closed (ZBP-2-R49)
- [ ] No transactional write on the hot path (ZBP-2-R50)
- [ ] Every issuance and refusal audited with the full record (ZBP-2-R51)
- [ ] Audit contains no tokens or key material and is append-only to the STS (ZBP-2-R52)

**Test suite**

- [ ] S1 and T1–T6 produce the stated outcomes with the clock frozen at the reference time
- [ ] T6 is asserted explicitly — a forged `kid` must not be trusted, and must not amplify
- [ ] T7a, T7b, and T7c are run against both blueprints together, not either one alone — in
      particular T7c, which proves a proof cannot be moved between tokens
- [ ] Rotation is tested by actually rotating in a non-production environment, including the overlap
      window and a relying party with a cold cache

## Rationale

The argument for putting workload identity in shared platform infrastructure rather than
reimplementing it per service is [ZFN-5](/zfn/5-platform-workload-identity-service/); for deleting
static cloud keys in favour of exchanging the runtime's own identity,
[ZFN-9](/zfn/9-no-long-lived-cloud-keys/). That every automated actor is a named principal — and that
`system` in an audit log means attribution is already lost — is
[ZFN-40](/zfn/40-no-anonymous-system-actor/), with the delegation position in
[ZFN-38](/zfn/38-agents-are-principals/). Local verification instead of a lookup, and storing
revocations rather than issuances, is [ZFN-49](/zfn/49-verify-by-computation-not-lookup/). The
control-plane/data-plane split that keeps issuance alive through a config outage is
[ZFN-16](/zfn/16-separate-data-plane-control-plane/), and the requirement that incident tooling not
depend on what may be broken is [ZFN-4](/zfn/4-incident-tooling-independence/).

One position is specific to this document. An STS is usually sold on security, but the security part
is the easy part — it is a few signature checks and a scope comparison. What actually determines
whether this succeeds is that you have taken a property every service used to have locally (a
credential in its config, which worked whether or not anything else was up) and replaced it with a
dependency on a service that can be down. Everything hard about an STS follows from that: the
bootstrap, the self-dependency rule, the control-plane split, the short lifetimes fighting the
availability budget. Build the availability story first and the token exchange second; the reverse
order produces a system that is beautifully specified and the first thing to fail in an incident.

## Changelog

- **2026-07-27** (1.0.0): First published. Specifies the identity namespace, attestor enrolment and
  verification, the fifteen-step exchange algorithm, delegation via `act`/`may_act`, the `at+jwt`
  issued token format, KMS key custody with overlapping rotation, local verification by relying
  parties, revocation-by-storage-inversion, availability and bootstrap constraints, audit, a staged
  rollout, and test vectors S1 and T1–T7.
