## 1. token request -- the WAG floor: bearer grant, no client identity POST /token HTTP/1.1 Host: as.saas.example Content-Type: application/x-www-form-urlencoded grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer &assertion=eyJhbGciOiJFUzI1NiIs... # signed by PLATFORM per-tenancy issuer &resource=https%3A%2F%2Fapi.saas.example%2F ## 2. decoded: the WAG grant
{ "alg": "ES256", "kid": "2026-07-14", "typ": "JWT" }
.
{
"iss": "https://acme.agents.platform.example",
"sub": "wimse://acme.agents.platform.example/agent/7f3d9a2e",
"aud": ["https://as.saas.example",
"https://as.saas.example/token"],
"exp": 1785271980, "iat": 1785271680,
"jti": "7d0f5a2b-93c8-4f0e-9c33-1b6a0e6d5f10",
"name": "Support Triage Agent",
"namespace": "acme/support",
"groups": ["support-eng"],
"roles": ["responder"],
"ctx": "channel:C0123456789"
}
# BEARER: anyone holding this within exp can redeem it once. # mitigations: AS jti replay cache, short exp, valid only at /token. ## 3. AS response { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 900 } ## 4. using it at the RS (many times, until it expires) GET /docs/42 HTTP/1.1 Host: api.saas.example Authorization: Bearer eyJ...access.token... # RS untouched: it trusts its AS exactly as before.
Client Platform AS RS | | | | |<- (0) WAG assertion ------| | | one signed assertion per token request | | | | |-- (1) POST /token: assertion + resource ------------->| | | | | | (2) AS: iss allowlisted? JWKS by iss; unseen sub -> accept; jti replay? | | | | |<- (3) access token -----------------------------------| | ordinary bearer, aud = RS | | | | |-- (4) GET /resource with Authorization: Bearer ---------------------------->| | | | | |<- 200 ----------------------------------------------------------------------| | | | | |-- (5) ...more calls, same token, until it expires ------------------------->| | | | | |<- 200 ----------------------------------------------------------------------| | | | | (6) token expired -> new assertion, new token: repeat (0)-(3) | | | | 1 assertion : 1 token request : many RS calls. RS never changes.
## 1. token request -- same as grant-only, plus one header POST /token HTTP/1.1 Host: as.saas.example DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs... # proof #1, signed by CLIENT instance key Content-Type: application/x-www-form-urlencoded grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer &assertion=eyJhbGciOiJFUzI1NiIs... # signed by PLATFORM per-tenancy issuer &resource=https%3A%2F%2Fapi.saas.example%2F ## 2. decoded: the WAG grant (unchanged)
{ "alg": "ES256", "kid": "2026-07-14", "typ": "JWT" }
.
{
"iss": "https://acme.agents.platform.example",
"sub": "wimse://acme.agents.platform.example/agent/7f3d9a2e",
"aud": ["https://as.saas.example",
"https://as.saas.example/token"],
"exp": 1785271980, "iat": 1785271680,
"jti": "7d0f5a2b-93c8-4f0e-9c33-1b6a0e6d5f10",
"name": "Support Triage Agent",
"namespace": "acme/support",
"groups": ["support-eng"],
"roles": ["responder"],
"ctx": "channel:C0123456789"
}
## 2b. decoded: DPoP proof #1 (token endpoint)
{ "typ": "dpop+jwt", "alg": "ES256",
"jwk": { "kty":"EC", "crv":"P-256", "x":"...instance public key...", "y":"..." } }
.
{ "htm": "POST",
"htu": "https://as.saas.example/token",
"iat": 1785271680,
"jti": "b3c1..." }
## 3. AS response { "access_token": "eyJ...", "token_type": "DPoP", "expires_in": 900 } # token now carries cnf.jkt = thumbprint of the instance key ## 4. using it at the RS GET /docs/42 HTTP/1.1 Host: api.saas.example Authorization: DPoP eyJ...access.token... DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs... # proof #2: htm GET, htu, ath=hash(token), jti # RS-side change required: validate proof #2 against cnf.jkt. # breaks "only the token endpoint changes". # note: DPoP DOES protect access-token theft -- a stolen token is # useless without the instance key. it does NOT protect a stolen # assertion: a thief presents their own DPoP key and gets a fresh # token bound to it. to close that, put cnf (instance key) in the # assertion and have the AS require the DPoP key to match.
Client Platform AS RS | | | | |<- (0) WAG assertion ------| | | | | | | |-- (1) POST /token: assertion + DPoP proof#1 --------->| | | | | | (2) AS: grant checks; proof#1 sig; bind token to jkt(instance key) | | | | |<- (3) DPoP-bound token -------------------------------| | | | | | |-- (4) GET /resource: Authorization: DPoP + proof#2 (ath) ------------------>| | | | | (5) RS validates proof#2 vs cnf.jkt <- RS-side change | | | | |<- (6) 200 ------------------------------------------------------------------| | | | |
## 1. token request -- the full stack: Platform authenticates as an ## OAuth client (CIMD + private_key_jwt), carries the WAG grant, DPoP ## binds the issued token. stays open for grant-issuer = Enterprise IdP. POST /token HTTP/1.1 Host: as.saas.example DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs... # proof #1, signed by CLIENT instance key Content-Type: application/x-www-form-urlencoded grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer &assertion=eyJhbGciOiJFUzI1NiIs... # WAG grant (issuer could be the customer's IdP) &resource=https%3A%2F%2Fapi.saas.example%2F &client_id=https%3A%2F%2Fagents.platform.example%2Foauth%2Fclient # the CIMD URL &client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer &client_assertion=eyJhbGciOiJFUzI1NiIs... # private_key_jwt, key published in the CIMD ## 2. decoded: the WAG grant (unchanged)
{ "alg": "ES256", "kid": "2026-07-14", "typ": "JWT" }
.
{
"iss": "https://acme.agents.platform.example",
"sub": "wimse://acme.agents.platform.example/agent/7f3d9a2e",
"aud": ["https://as.saas.example",
"https://as.saas.example/token"],
"exp": 1785271980, "iat": 1785271680,
"jti": "7d0f5a2b-93c8-4f0e-9c33-1b6a0e6d5f10",
"name": "Support Triage Agent",
"namespace": "acme/support",
"groups": ["support-eng"],
"roles": ["responder"],
"ctx": "channel:C0123456789"
}
## 2b. decoded: DPoP proof #1 (unchanged from grant + DPoP)
{ "typ": "dpop+jwt", "alg": "ES256",
"jwk": { "kty":"EC", "crv":"P-256", "x":"...instance public key...", "y":"..." } }
.
{ "htm": "POST", "htu": "https://as.saas.example/token", "iat": 1785271680, "jti": "b3c1..." }
## 2c. decoded: client_assertion (CLIENT AUTH slot -- authenticates the Platform)
{ "alg": "ES256", "kid": "platform-client-2026" }
.
{ "iss": "https://agents.platform.example/oauth/client",
"sub": "https://agents.platform.example/oauth/client", # iss = sub = client_id
"aud": "https://as.saas.example",
"exp": 1785271980, "jti": "9a4e..." }
## 2d. the CIMD, fetched by the AS from the client_id URL
{
"client_id": "https://agents.platform.example/oauth/client",
"client_name": "Acme Agents Platform",
"token_endpoint_auth_method": "private_key_jwt",
"jwks": { "keys": [ { "kty":"EC", "kid":"platform-client-2026", ... } ] }
}
# no registration step: the AS discovers the client by dereferencing
# client_id. same by-reference trust move as the WAG issuer allowlist.
## 3. AS response { "access_token": "eyJ...", "token_type": "DPoP", "expires_in": 900 } ## 4. using it at the RS (unchanged from grant + DPoP) GET /docs/42 HTTP/1.1 Host: api.saas.example Authorization: DPoP eyJ...access.token... DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs... # proof #2: htm GET, htu, ath, jti # slots recap: client_assertion answers "who is calling" (Platform); # assertion answers "issue a token FOR this subject" (the agent); # DPoP binds what comes back. three independent layers.
Client Platform AS RS | | | | |<- (0) WAG assertion ------| | | | | | | |-- (1) POST /token: client_id=CIMD-url + pkjwt ------->| | | | | | plus assertion, DPoP proof#1, resource | | | | | |<- (2) GET client_id URL --| | | | | | | |-- (3) CIMD doc (jwks) --->| | | | | | (4) AS: pkjwt vs CIMD jwks; iss allowlist; DPoP proof#1 | | | | |<- (5) DPoP-bound token -------------------------------| | | | | | |-- (6) GET /resource: DPoP token + proof#2 --------------------------------->| | | | | |<- (7) 200 ------------------------------------------------------------------| | | | |
## 0. ahead of time (platform-internal): instance makes a keypair, ## attester binds it into a Client Attestation JWT ## 1. token request -- grant-only plus the two attestation headers POST /token HTTP/1.1 Host: as.saas.example OAuth-Client-Attestation: eyJ0eXAiOiJvYXV0aC1jbGllbnQtYXR0ZXN0YXRpb24... # signed by ATTESTER OAuth-Client-Attestation-PoP: eyJ0eXAiOiJ...cG9w... # signed by CLIENT instance key Content-Type: application/x-www-form-urlencoded grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer &assertion=eyJhbGciOiJFUzI1NiIs... # signed by PLATFORM per-tenancy issuer &resource=https%3A%2F%2Fapi.saas.example%2F ## 2. decoded: the WAG grant (unchanged)
{ "alg": "ES256", "kid": "2026-07-14", "typ": "JWT" }
.
{
"iss": "https://acme.agents.platform.example",
"sub": "wimse://acme.agents.platform.example/agent/7f3d9a2e",
"aud": ["https://as.saas.example",
"https://as.saas.example/token"],
"exp": 1785271980, "iat": 1785271680,
"jti": "7d0f5a2b-93c8-4f0e-9c33-1b6a0e6d5f10",
"name": "Support Triage Agent",
"namespace": "acme/support",
"groups": ["support-eng"],
"roles": ["responder"],
"ctx": "channel:C0123456789"
}
## 2b. decoded: Client Attestation JWT (attester vouches for the instance key)
{ "typ": "oauth-client-attestation+jwt", "alg": "ES256", "kid": "attester-2026-07" }
.
{ "iss": "https://attester.platform.example",
"sub": "wimse://acme.agents.platform.example/agent/7f3d9a2e",
"exp": 1785275280,
"cnf": { "jwk": { "kty":"EC", "crv":"P-256", "x":"...instance key...", "y":"..." } } }
## 2c. decoded: Client Attestation PoP JWT
{ "typ": "oauth-client-attestation-pop+jwt", "alg": "ES256" }
.
{ "iss": "wimse://acme.agents.platform.example/agent/7f3d9a2e",
"aud": "https://as.saas.example",
"exp": 1785271980, "jti": "f81a..." }
# signed with the instance-held key that cnf above binds.
# a stolen WAG assertion without this signature gets nothing.
## 3. AS response { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 900 } ## 4. using it at the RS (unchanged: plain bearer, RS untouched) GET /docs/42 HTTP/1.1 Host: api.saas.example Authorization: Bearer eyJ...access.token...
Client Attester AS RS | | | | |-- (1) instance pubkey --->| | | | | | | |<- (2) Attestation JWT ----| | | cnf = instance key | | | | |-- (3) POST /token: attestation hdrs + WAG grant ----->| | | | | | OAuth-Client-Attestation (attester-signed) | | | | OAuth-Client-Attestation-PoP (instance-signed) | | | | (4) AS: attester sig; PoP vs cnf; grant iss allowlist | | | | |<- (5) access token -----------------------------------| | plain bearer, aud = RS | | | | |-- (6) GET /resource with Authorization: Bearer ---------------------------->| | | | | |<- (7) 200 ------------------------------------------------------------------| | | | |
sequenceDiagram participant C as Client (instance) participant P as Attester (Platform) participant AS as Authorization Server participant RS as Resource Server C->>P: (1) instance public key P->>C: (2) Client Attestation JWT (cnf = instance key) C->>AS: (3) POST /token + attestation headers + WAG grant AS->>AS: (4) verify attester sig, PoP vs cnf, grant issuer allowlist AS->>C: (5) access token (short-lived, aud = RS) C->>RS: (6) request + Bearer token RS->>C: (7) resource
| attacker steals... | grant only | grant + DPoP | CIMD/pkjwt + grant | CIMD/pkjwt + grant + DPoP | attest + grant |
|---|---|---|---|---|---|
| the assertion alone (log leak, queue, cross-domain handoff) |
redeem once, within exp | redeem with own DPoP key | worthless — needs Platform client key* | worthless — needs Platform client key* | worthless — needs instance key |
| a whole token request (inside TLS termination) |
1 redemption if the thief wins the race (jti cache) | replay window only; proof jti cache closes it | replay window only; jti caches close it | replay window only; jti caches close it | replay window only; PoP jti cache closes it |
| a durable key (the long-lived secret) |
no client key exists (issuer key = game over, same for every column) | instance key → that instance's tokens only (no extra platform key) | Platform client key → redeems every tenant's stolen assertions | Platform client key → redeems every tenant's stolen assertions | instance keys are per-instance, BUT the attester key is platform-wide (≈ issuer key) — as bad as the pkjwt client key |
| an access token | usable (bearer) | useless without instance key | usable (bearer; no DPoP layer) | useless (DPoP layer) | usable (still bearer; add DPoP) |
| who must change | AS only | AS + RS | AS only | AS only (AS+RS with the DPoP layer) | AS only |