Skip to content

Token lifecycle

TokenLifetimeNotes
Authorization code60 secondsSingle-use, PKCE-bound
Access token1 hour
ID token1 hour
Delegated token (token exchange)5 minutesDeliberately short — an on-behalf-of token should not outlive the task
Refresh token30 daysRotated on every use
Verifiable credential90 days
PAR request_uri90 secondsSingle-use
Admin Portal link15 minutesSingle-use; yields a 1-hour session

A refresh token is issued only when offline_access was among the granted scopes. It looks like rt_…, is stored hashed, and rotates on every use: each refresh returns a new token and consumes the old one.

POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=rt_…&client_id=client_7f3a…
{
"access_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile offline_access",
"refresh_token": "rt_…",
"id_token": "eyJ…"
}

Store the new refresh_token immediately. Continuing to use the previous one is not merely an error — see below.

You may narrow scope on refresh by passing a scope that is a subset of the original. A request for anything outside the original grant is ignored rather than honoured, so scope can only ever shrink.

Confidential clients must authenticate to refresh. A DPoP-bound refresh token requires a proof from the same key it was issued under — the binding survives rotation.

Rotation makes theft detectable. RFC 9700: if a refresh token that has already been rotated is presented again, the legitimate client is holding the successor, so the replay means the token leaked.

The response is 400 invalid_grant — and, more importantly, the entire rotation family is revoked. Every descendant of that token stops working, and a refresh_reuse_detected event lands on the audit log.

The same applies to a race: two concurrent requests presenting the same refresh token means only one can win, and the loser is treated as reuse. If your client refreshes from multiple threads or tabs, serialize it behind a single-flight lock. A refresh storm looks exactly like theft, and is handled as such.

If users are being signed out unexpectedly, check for refresh_reuse_detected before assuming a TTL problem.

RFC 7662. Unlike the rest of the protocol surface, introspection authenticates with a management API key, not a client secret:

POST /introspect
Authorization: Bearer sk_live_…
Content-Type: application/x-www-form-urlencoded
token=eyJ…
{
"active": true,
"token_type": "access_token",
"scope": "openid profile",
"client_id": "client_7f3a…",
"sub": "usr_01J…",
"org_id": "org_01J…",
"aud": "https://tools.acme.com/mcp"
}

It accepts both access tokens and refresh tokens, reporting which it found in token_type; a refresh token also returns exp. Anything unknown, expired, or revoked returns { "active": false } without further detail.

An org-scoped key introspecting a token from another org gets { "active": false } — not an error, and not a leak of the token’s contents.

RFC 7009. The client that owns the token revokes it:

POST /revoke
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded
token=rt_…

The endpoint always returns 200, even for an unknown token, a token belonging to another client, or an unparseable one. That is intentional: a revocation endpoint that distinguished these cases would be an oracle for probing token validity.

  • Refresh tokens are revoked directly.
  • Access tokens have their jti denylisted until their natural expiry, so a revoked access token fails verification for the remainder of its hour rather than staying valid because it is signed.

Revoking one token does not revoke the whole family — for that, either trigger reuse detection or revoke the user’s sessions. To sign a user out everywhere, use session revocation.

token_type is Bearer unless the token request carried a DPoP proof, in which case it is DPoP and every subsequent request must present a matching proof. See DPoP.