Skip to content

Narrow a token

An application often needs to hand a credential to something it does not fully trust — a background run, an enrolled machine, a nested agent. Handing over the user’s own token gives that thing everything the user can do, for as long as the user’s session lasts.

Token exchange (RFC 8693) mints a strictly narrower token from one you already hold.

Terminal window
curl -X POST https://jons.auth.example/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="$ACCESS_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d scope="openid profile" \
-d resource=https://jons.things.example/mcp \
-d requested_lifetime=300
{
"access_token": "eyJraWQiOi…",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 299,
"scope": "openid profile"
}
scope must be a subset of the subject token’s
audience must be a subset of the subject token’s resource values
lifetime never outlives the subject token, whatever you ask for

Ask for more than you hold and it is refused, not silently trimmed:

{"error":"invalid_scope",
"error_description":"an exchange cannot widen scope: email, offline_access is not carried by the subject token"}

Omit scope or resource and the new token inherits the subject’s. The lifetime ceiling is not optional — a 300-second request against a subject with 120 seconds left yields 120.

The exchanged token names the client that performed the exchange, and chains on each further exchange:

"act": { "sub": "a46deb60-…", "act": { "sub": "9c1e2f77-…" } }

So a token carries its own provenance. Anything reading it can see it was not minted directly for the subject, and by whom.

Exchanged tokens record their parent, which makes cancellation a tree operation rather than a hunt:

Terminal window
curl -X POST https://jons.auth.example/revoke \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d token="$ROOT_ACCESS_TOKEN" \
-d token_type_hint=access_token

Revoking a token revokes everything exchanged from it, and everything exchanged from those. A child cannot outlive its parent, and a revoked token cannot be exchanged.

narrowed = session.exchange(
tokens.access_token,
scope: %w[openid profile],
resource: "https://jons.things.example/mcp",
lifetime: 300
)
session.revoke(tokens.access_token, hint: "access_token")

A client may only exchange if it was registered for the grant:

"grant_types": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:token-exchange"
]

Otherwise the exchange is refused with unauthorized_client.