Skip to content
TDACorpIDENTITYSign in

Refresh tokens and offline access

How to keep a person signed in without asking again, how rotation and reuse detection work, and how to revoke.

On this page

An access token lives for at most 15 minutes. A refresh token lets your server get a new one without sending the person through sign-in again. It is the most valuable thing your server stores, so identity treats it strictly: it can be used once, it is tied to your key, and presenting an old one is treated as theft.

When you get one#

A refresh token is issued with the first set of tokens when both are true:

  • the client is registered with the refresh_token grant, and
  • the request asked for the offline_access scope.

Without offline_access the token response has no refresh_token. The person sees this scope on the consent screen as Stay signed in, and it is listed under their connected apps, where they can end it. See Consent screens.

Use it#

POST /tokenContent-Type: application/x-www-form-urlencodedDPoP: <proof>grant_type=refresh_token&client_id=<client id>&refresh_token=<the current refresh token>&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=<assertion>

You may add scope to narrow the new access token to some of the scopes the family holds, and resource to choose the API when several were authorized; the resource's app must still be installed in the family's organization at that moment. A scope outside what the family holds is refused with invalid_scope. A refresh can narrow but never widen.

The answer has a new access token, a new refresh token and the scope, and may include an ID token. Store the new refresh token before you discard the old one.

import { OAuthEndpointError } from '@tdacorp/identity-sdk/rp'import { rp } from './relying-party'// Returns the new token set. Persist tokens.refreshToken before you discard the old one.export async function refreshSession(refreshToken: string) {  try {    return await rp.refresh(refreshToken)  } catch (error) {    if (error instanceof OAuthEndpointError) {      // The SDK already retried a 429 a bounded number of times and never retries a 5xx: what to do now is your decision.      if (error.reason === 'rate_limited') throw new Error(`retry in ${String(error.retryAfterSeconds ?? 60)} s`, { cause: error })      // The refresh family is over (revoked, expired or reused): sign the person in again.      if (error.oauthError === 'invalid_grant') return null    }    throw error  }}

Rotation#

Every successful refresh replaces the refresh token. The old one is spent, and the new one carries on the same family. Three things follow.

  • Serialize refreshes per token. Two requests that present the same refresh token at once are a reuse.
  • Persist the replacement atomically. If your process dies between receiving the new token and saving it, you have lost the family and the person must sign in again.
  • A retry is forgiven for a moment. If a response is lost and you present the same token again within about ten seconds (at most; a deployment can shorten it), identity returns the same successor rather than treating it as a replay. After that window it is a reuse.

Reuse detection#

A refresh token that is presented after it was spent, beyond that short grace, is assumed stolen, because there is no way to tell your retry from a thief's. Identity then:

  • answers invalid_grant,
  • revokes the whole token family, so the token that was legitimately rotated to stops working too, and
  • ends the identity session behind it.

The person is signed out and has to sign in again. This is the system working: it makes a stolen refresh token a one-time liability that also warns the real owner. If your own bug causes it, the cause is almost always two workers refreshing the same token at once.

Bound to your key#

When your client requires DPoP, the family is bound to the thumbprint of the key that was used at sign-in. A refresh made with another key is refused with invalid_dpop_proof, and a refresh token stolen without your key is useless. Keep the same DPoP key for as long as refresh tokens issued under it exist. See DPoP.

Lifetime#

ClientSliding windowAbsolute limit
Third-partyThe client's setting, at most 7 daysAt most 30 days from sign-in
First-partyThe client's settingThe session's absolute end

A deployment can shorten the third-party limits, never lengthen them. Each refresh slides the window forward, never past the absolute limit. A refresh token can never outlive the identity session it came from. When that session ends, whether by sign-out, by the person revoking the app or by its absolute expiry, the refresh token stops working. See Sessions and tokens for the session lifetimes.

Revoke#

When your user signs out of your application, or you detect a problem, revoke the token so it cannot be used.

POST /revokeContent-Type: application/x-www-form-urlencodedDPoP: <proof>client_id=<client id>&token=<refresh token or access token>&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=<assertion>
  • You authenticate as your client, with the same assertion and, if required, DPoP proof (without ath).
  • Revoking a refresh token revokes its whole family. Revoking an access token blocks that one token.
  • The answer is 200 with an empty body even if the token was unknown or already revoked, so the endpoint tells nobody which tokens exist. You can only revoke your own client's tokens.

With the SDK, rp.revoke(token).

What can go wrong#

AnswerMeaning
invalid_grantThe refresh token is unknown, expired, revoked, spent, or its session has ended (including a session started through a single sign-on connection that is no longer linked to the family's organization). Also returned when the client no longer holds a scope the family had. Send the person through sign-in again.
invalid_dpop_proofThe proof is for a different key than the family.
insufficient_user_authenticationRFC 9470. The family's organization requires single sign-on (the sso_enforcement flag, default OFF) and the session has no recent enough single sign-on login. The body adds acr_values and max_age; send the person through a new authorization asking for both (the SDK's rp.stepUpUrl).
invalid_scopeYou asked for a scope the family does not hold.
invalid_clientClient authentication failed. See Client authentication.
use_dpop_nonceRetry with the nonce in the DPoP-Nonce header. The SDK does this once.
429Too many requests. Wait for Retry-After. See Rate limits and retries.

On any invalid_grant, do not retry with the same token. Drop your session and start a new sign-in.

Verified against commit ca46b57