Blueprint 2stablev1.0.0
A Security Token Service for workload identity
Pull this in
https://zrz.io/zbp/2-workload-security-token-service/v1.mdVersion-pinned. Latest tracks revisions; the pinned URL does not. Requirements are cited individually as ZBP-2-Rk, so an implementation can annotate and a review can check them one at a time.
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 exchangeRFC 8693 — OAuth 2.0 Token ExchangeDefines 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.rfc-editor.org ↗ issuing
RFC 9068 at+jwtRFC 9068 — JSON Web Token (JWT) Profile for OAuth 2.0 Access TokensPins 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.rfc-editor.org ↗ access tokens, optionally bound to a holder key via
ZBP-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open ZBP-1 →. 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-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open ZBP-1 →, 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-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open 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
Four properties are load-bearing, and each one is a requirement below rather than a nice-to-have:
- 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.
- 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.
- 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.
- 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 IDSPIFFE ID — Secure Production Identity Framework for EveryoneA 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.github.com ↗ 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 8693RFC 8693 — OAuth 2.0 Token ExchangeDefines 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.rfc-editor.org ↗ 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_tokenandsubject_token_type, and MUST name the intended audience viaaudienceorresource. 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_tokenis present, the STS MUST verify it as it verifies a subject token, MUST confirm that policy — expressed as the subject’smay_act— permits that actor, and MUST record the actor in the issued token’sactclaim. An unverified or unpermitted actor MUST cause rejection, never issuance without theactclaim. - 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
typset toat+jwtper RFC 9068RFC 9068 — JSON Web Token (JWT) Profile for OAuth 2.0 Access TokensPins 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.rfc-editor.org ↗, 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, andclient_id.jtiMUST be unique per issuance. - ZBP-2-R27
audMUST be a single string naming one audience, andissMUST 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
noneor MAC-based. The header MUST carrykid. - 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
kidMUST be the RFC 7638RFC 7638 — JSON Web Key (JWK) ThumbprintDefines 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.rfc-editor.org ↗ 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 8414RFC 8414 — OAuth 2.0 Authorization Server MetadataA 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.rfc-editor.org ↗ 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
kidso that a forgedkidcannot 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
typisat+jwt; thatalgis in its own configured permitted set; thatkidresolves in the cached JWKS; that the signature verifies; thatissexactly matches the configured issuer; and that the token is withinnbf/exp. - ZBP-2-R40 The resource server MUST reject a token whose
audis 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-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open ZBP-1 → validation, including the thumbprint comparison in ZBP-1-R20, and MUST NOT accept the token under theBearerscheme (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, thecnf.jktif 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
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
{
"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
{
"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:
{
"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
{
"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
{
"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/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-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open 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
kidtriggering 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
nbfequal toiat, 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).
systemin the audit log. If the actor issystem, 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
actexists 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):
{
"kty": "EC", "crv": "P-256",
"x": "ee40l9RwHHYggPkjfX2DhtWIQvaEpWZCzb9o_6JGC3Y",
"y": "VgOc0ZysEjvOss6tMyX8tbFgRud1fK4cY6UBnGuE0tE",
"d": "ianuunTXUNmDgK7Agw_0CWsoTLKVFd2ZaL6-e_2oZ2U"
}
The attestor (platform OIDC) public key:
{
"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-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open ZBP-1 →’s test key) |
| Reference time | 1782000000 (2026-06-21T00:00:00Z) |
The reference time is the same as ZBP-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open ZBP-1 →’s, and the cnf.jkt is ZBP-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open 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-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open 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-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open 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-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open 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-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open 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:
- 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.
- 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).
- 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.
- Remove the static path at that audience. Only then. The resource server rejects everything but issued tokens.
- Repeat per audience, highest-value first. Each repetition is cheaper; the expensive parts — attestation, key custody, rotation, caching — are already built and exercised.
- Turn on binding where it earns its cost (ZBP-2-R23, ZBP-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open ZBP-1 →). Bound tokens per ZBP-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open ZBP-1 → are a separate, later increment: get identity working first, then constrain the holder.
- 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;actrecorded (ZBP-2-R20) - No long-lived client secret required (ZBP-2-R21)
Binding
-
cnf.jktset 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
-
typisat+jwt(ZBP-2-R25) - All required claims present;
jtiunique (ZBP-2-R26) - Single-string
aud; stableiss(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, withkid(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)
-
kidis 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-
kidrefetches (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)
-
audcompared by exact match only (ZBP-2-R40) - Absent authority denies (ZBP-2-R41)
- ZBP-1Blueprint · v1.0.0ZBP-1 — DPoP-bound requests across an API surfaceYou are issuing or accepting access tokens over HTTP and want a stolen token to be useless without the holder's private key.Why it's cited here: 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.Open 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
kidmust 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-5Field Note · currentZFN-5 — Make workload identity a platform-owned serviceWorkload identity belongs in shared platform infrastructure, not reimplemented per service. A small token service mints short-lived tokens any service verifies. Shared keys are a fine first step; asymmetric signing the better end-state — don't let 'no PKI' block it.Why it's cited here: The position this blueprint implements. That note argues for one shared token service rather than per-service identity; this specifies what to build.Open ZFN-5 →; for deleting
static cloud keys in favour of exchanging the runtime’s own identity,
ZFN-9Field Note · currentZFN-9 — No long-lived cloud keys; workloads authenticate by federated identityNo static AWS or GCP keys anywhere — not in code, secret stores, or env. Workloads use their runtime's own identity and cross clouds by exchanging it (OIDC) for short-lived credentials via federation. Static keys are a documented carve-out only.Why it's cited here: 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.Open ZFN-9 →. That every automated actor is a named principal — and that
system in an audit log means attribution is already lost — is
ZFN-40Field Note · currentZFN-40 — No anonymous "system" actorIf "system" appears as an actor in your audit log, attribution is already broken. Every automated action — cron job, cleanup task, migration, agent — runs as a named identity with its own credentials and scope, so "who did this?" has an answer and revocation is surgical.Why it's cited here: Why every issuance names a principal and why the audit record in ZBP-2-R51 carries both subject and actor.Open ZFN-40 →, with the delegation position in
ZFN-38Field Note · currentZFN-38 — Agents are principals: delegate, never impersonateAn agent acting with a copied user credential is impersonation — untraceable by design. Give agents their own identities and keys; let them act for a human only through explicit, scoped, time-bounded, revocable delegation; and record both actor and principal on every action.Why it's cited here: The basis for the actor-token and delegation-claim requirements rather than credential copying.Open ZFN-38 →. Local verification instead of a lookup, and storing
revocations rather than issuances, is ZFN-49Field Note · currentZFN-49 — Verify by computation, not lookup; store revocations, not issuancesVerification should be a computation, not a query: HMACs, signatures, hashes, and pass-by-value claims let any node verify locally. When revocation is rarer than issuance, invert the state — keep the few revocations for the lifetime of what they revoke, not a row per grant.Why it's cited here: 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.Open ZFN-49 →. The
control-plane/data-plane split that keeps issuance alive through a config outage is
ZFN-16Field Note · currentZFN-16 — Separate the data plane from the control planeSplit the serving path (data plane) from the management path (control plane). The data plane keeps serving on last-known-good config when the control plane is down — never call it on the hot path. Coupling them turns a control-plane bug into a serving outage.Why it's cited here: The rationale for ZBP-2-R46: issuance runs from pushed, cached config and never calls the control plane.Open ZFN-16 →, and the requirement that incident tooling not
depend on what may be broken is ZFN-4Field Note · currentZFN-4 — Incident tooling must not depend on what it recoversAnything you need to respond to an incident — deploy/rollback, kill switches, observability, break-glass access — must not depend, directly or transitively, on the systems likely to be down during it. Never gate incident tooling behind a system it might need to recover.Why it's cited here: Directly behind ZBP-2-R48: an STS becomes a tier-zero dependency, so break-glass access cannot route through it.Open ZFN-4 →.
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, theat+jwtissued 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.