Working through an example
The printer is assembling your photo book and asks for the photos in album 42. Most of them sit in the storage service. One, photo-0311, was taken in 2019 and you have not opened it in years, so its original has moved to the archive service at https://archive.photos.example.
Fetching it involves four services and two exchanges. Every token along the way appears below, decoded, so you can see what changes at each hop and what stays the same.
Starting at the photo API
All domains, credentials, and tokens in this example are fictional. The three tokens share one header, {"alg": "ES256", "kid": "photos-2026-09", "typ": "at+jwt"}, so only their claims are shown. At 09:02:00 UTC, the printer sends its request:
GET /albums/42/photos HTTP/1.1
Host: api.photos.example
Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6InBob3Rvcy0yMDI2LTA5IiwidHlwIjoiYXQrand0In0.eyJpc3Mi...
The token, shortened above, is the printer's access token. Call it token A. Its claims:
{
"iss": "https://auth.photos.example",
"sub": "user-2048",
"aud": "https://api.photos.example",
"client_id": "photo-printer",
"scope": "photos.read",
"iat": 1790845200,
"exp": 1790845800,
"jti": "demo-token-id-7"
}
The photo API validates token A. The audience is the photo API, photos.read covers listing photos, and album 42 belongs to user-2048. There is no act claim. The printer is an ordinary OAuth client acting with your authorization, and client_id records it.
The first exchange
The photo API exchanges token A just as it did in Following a token exchange. Authenticated as photo-api, it sends this body to the token endpoint:
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&subject_token=eyJhbGciOiJFUzI1NiIsImtpZCI6InBob3Rvcy0yMDI2LTA5IiwidHlwIjoiYXQrand0In0.eyJpc3Mi...
&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
&resource=https%3A%2F%2Fstorage.photos.example
&scope=storage.read
Token B comes back with an expires_in of 60:
{
"iss": "https://auth.photos.example",
"sub": "user-2048",
"aud": "https://storage.photos.example",
"client_id": "photo-api",
"scope": "storage.read",
"iat": 1790845320,
"exp": 1790845380,
"jti": "demo-token-id-storage-1",
"act": {
"sub": "photo-api"
}
}
Token B is addressed to the storage service, carries the storage service's scope, and names photo-api as the current actor. The photo API sends it with each storage request. For photo-0311, the storage service validates token B, confirms that the photo belongs to user-2048 and that the actor is the photo API, and finds only a note saying the original is in the archive.
The second exchange
Now the storage service stands where the photo API stood. It holds a token issued for it, token B, and needs a token for another service. It is registered as client photo-storage, and the photo service's policy lets that client exchange a token carrying storage.read for archive.read at the archive. At 09:02:01 it sends:
POST /token HTTP/1.1
Host: auth.photos.example
Authorization: Basic cGhvdG8tc3RvcmFnZTpkZW1vLW9ubHktbm90LWEtcmVhbC1zZWNyZXQ=
Content-Type: application/x-www-form-urlencoded
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&subject_token=eyJhbGciOiJFUzI1NiIsImtpZCI6InBob3Rvcy0yMDI2LTA5IiwidHlwIjoiYXQrand0In0.eyJpc3Mi...
&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
&resource=https%3A%2F%2Farchive.photos.example
&scope=archive.read
The subject token is token B, shortened, and the Basic header represents photo-storage:demo-only-not-a-real-secret. The authorization server checks that photo-storage may exchange tokens, that token B was issued for the storage service and is still valid, that archive.read follows from storage.read, and that the archive is a target this client may reach. It returns token C with an expires_in of 59:
{
"iss": "https://auth.photos.example",
"sub": "user-2048",
"aud": "https://archive.photos.example",
"client_id": "photo-storage",
"scope": "archive.read",
"iat": 1790845321,
"exp": 1790845380,
"jti": "demo-token-id-archive-1",
"act": {
"sub": "photo-storage",
"act": {
"sub": "photo-api"
}
}
}
The act claim now has two levels. The outer one names photo-storage, the current actor. Inside it, photo-api appears as the earlier actor. Read from the outside in, it is a history: the storage service is acting for you now, and before it, the photo API was.
The expiry repays a closer look. A full minute from 09:02:01 would end at 09:03:01, but token B expires at 09:03:00, and the photo service never lets a token outlive its subject token. So token C ends at 09:03:00 as well, and expires_in is 59 seconds. No token in the chain can outlast the one before it, though each may end sooner.
Notice who is missing. The printer appears in token A only as its client_id, and that claim names the client that requested each token, so it changes at every hop. The chain in act records the actors that exchanges created. To learn that the printer started all this, the archive's operators would follow the jti values back through the authorization server's exchange records. A deployment that wanted the archive itself to know would have to put that in the token deliberately.
What each service decided
The archive validates token C like any access token, then decides, considering only the top-level claims and the current actor. archive.read covers the request, photo-0311 belongs to user-2048, and the current actor is photo-storage, the only caller the archive expects. photo-api, nested inside, goes into the archive's log and plays no part in the decision. The archive returns the original, the storage service passes it to the photo API, and the photo API includes it in the album for the printer.
| Service | Token | Audience and scope | Current actor |
|---|---|---|---|
| Photo API | A | https://api.photos.example, photos.read | None; the client is photo-printer |
| Storage service | B | https://storage.photos.example, storage.read | photo-api |
| Archive service | C | https://archive.photos.example, archive.read | photo-storage |
The subject never changed: every service made its decision about user-2048. Everything else shifted at each hop. The audience moved to the next service, the scope moved into that service's vocabulary, the lifetime shrank to a minute or less, and the actor became whoever was holding the token.
Small changes to the example bring the earlier lessons back into play. If the storage service had asked for archive.delete, the server would have answered invalid_scope. If the printer had tried to exchange token A itself, it would have received unauthorized_client. If the storage service had waited until 09:03:05, token B would have expired and the second exchange would have failed with invalid_request. The storage service would report that failure to the photo API, which still holds a valid token A and could exchange it once more and retry.
Every service in this example knew the photo service's token endpoint and signing keys from its own configuration. The next group, Authorization server metadata, looks at how clients and services can discover that configuration, and how to decide whether to trust what they find.