Following a token exchange
It is 09:02 UTC on 1 October. The printer asks the photo API for the photos in album 42, presenting the access token it received at 09:00. The photo API validates the token, confirms the album is yours, and needs the first file from the storage service.
Everything is in place for an exchange. The photo API is registered as client photo-api, and the photo service's policy lets that client exchange tokens issued for the photo API for tokens to use at the storage service. What follows is every message, in order.
Sending the request
All domains, credentials, and tokens in these examples are fictional.
POST /token HTTP/1.1
Host: auth.photos.example
Authorization: Basic cGhvdG8tYXBpOmRlbW8tb25seS1ub3QtYS1yZWFsLXNlY3JldA==
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
&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
&resource=https%3A%2F%2Fstorage.photos.example
&scope=storage.read
The body is form-encoded, and its line breaks are for display. The Basic header represents photo-api:demo-only-not-a-real-secret. HTTP Basic keeps the example short. Inside a platform, the photo API would more often authenticate with a workload identity or mutual TLS, as Service-to-service access suggested.
grant_type selects token exchange. subject_token is the printer's token, shortened here, and subject_token_type says it is an access token from this server. requested_token_type asks for an access token in return. It is optional, and included to make the request explicit.
resource names where the new token will be used, as in Selecting the intended resource. scope names the access needed there, in the storage service's own terms. The storage service defines scopes such as storage.read for reading stored files, so the request does not ask for photos.read, which means something only at the photo API.
The authorization server authenticates the photo API, validates the printer's token, and applies its exchange policy. Trust and validation sets out those checks in order.
Reading the response
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InBob3Rvcy0yMDI2LTA5IiwidHlwIjoiYXQrand0In0.eyJpc3Mi...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 60
}
The new token arrives in access_token, shortened here. The field keeps its familiar name so that token exchange fits the ordinary token response, but the token inside it does not have to be an access token.
Two fields describe the token, and they answer different questions. issued_token_type is required and says what was issued, using the same identifiers as the request: here, an access token. token_type is also required and says how to use the token at an API, in the same sense as in every other token response. Bearer means present it as it is.
expires_in is recommended rather than required, and gives one minute. scope is missing because the server issued exactly what was asked for. It may leave scope out only in that case, so its absence here means storage.read. There is no refresh token, which is usual when one short-lived credential is exchanged for another.
Some exchanges issue something that is not used at an API at all. Suppose the photo API asked for requested_token_type=urn:ietf:params:oauth:token-type:jwt, to present as an assertion at another authorization server, the way Lantern's portal presented one in JWT and SAML assertion grants. The response would then read:
{
"access_token": "eyJhbGciOiJFUzI1NiIs...",
"issued_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_type": "N_A",
"expires_in": 300
}
N_A says that no OAuth token type applies, because this token is not meant to be sent to an API.
Inside the new token
Decoded, the storage token reads:
Header:
{
"alg": "ES256",
"kid": "photos-2026-09",
"typ": "at+jwt"
}
Claims:
{
"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"
}
}
Compare it with the printer's token. The issuer and subject are unchanged: the photo service issued it, and it concerns user-2048. The audience is now https://storage.photos.example, matching the resource the photo API named. client_id is photo-api, because that is the client that requested this token, and act records the photo API as the party acting for you. The scope is the storage service's, and the token lives from 09:02:00 to 09:03:00 UTC.
The same key, photos-2026-09, signed it, and its typ is still at+jwt. To the storage service it is an ordinary access token from the photo service, validated in the ordinary way.
Using the new token
GET /objects/photo-1187 HTTP/1.1
Host: storage.photos.example
Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6InBob3Rvcy0yMDI2LTA5IiwidHlwIjoiYXQrand0In0.eyJpc3Mi...
The storage service validates the token, checks that storage.read covers the request, confirms that photo-1187 belongs to user-2048, and sees that the actor is the photo API, a caller it expects. It returns the file.
As Service-to-service access noted, the photo API can keep the storage token for its short life and reuse it for the album's other files. The cache needs one rule above all: an exchanged token is about one person, so it must be keyed by subject as well as audience, and never used for a request on behalf of someone else.
The exchange also leaves the printer's token as it was. Exchanging a token does not use it up, unless its type is defined to be single-use, so the printer's token stays valid at the photo API until 09:10. Nor are the two tokens linked afterwards. If the printer's token were revoked at 09:02:30, the storage token would not stop working on its own unless the deployment deliberately carries revocation across. Its one-minute lifetime keeps that gap short.