Skip to content

Refresh token rotation

Access tokens are short-lived on purpose. The shorter the lifetime, the smaller the window in which a leaked token is dangerous. But you do not want to send the user back through /oauth2/authorize every hour. That is what the refresh token solves.

The mental model: an access token says "the bearer can call the API for the next N minutes." A refresh token says "the bearer can ask for a new access token without re-authenticating the user." Both are bearer tokens, but the refresh token is rare, long-lived, and tightly guarded.

POST https://<your-tenant-url>/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=<the-current-refresh-token>
&client_id=<your-client-id>
&scope=openid+profile+email # optional — narrow the new token if desired

Response:

{
"access_token": "...",
"refresh_token": "...", // NEW — see "rotation" below
"id_token": "...",
"token_type": "Bearer",
"expires_in": 3600
}

The response includes a new refresh_token. The old one is immediately invalid.

This is "refresh token rotation". It catches a specific attack:

An attacker steals a refresh token (out of a leaky log, a compromised device, an XSS hit). They use it once. The legitimate client also tries to use it — and one of those two requests succeeds while the other fails.

When the platform sees the same refresh token used twice, it assumes leakage and invalidates the entire token family: the access token, the new refresh token issued in the first call, and any future refresh tokens descended from this one. Both the attacker and the legitimate client are kicked out — from the user's perspective, this looks like an unexpected sign-out the next time the app tries to refresh.

This is the trade: rotation is mildly annoying (every refresh changes the value you store), but it puts a hard cap on the damage a leaked refresh token can do.

If you use @intelliauth/react-sdk, you do not handle rotation directly. The SDK:

  • Watches the access token's exp claim.
  • About 60 seconds before expiry, calls /oauth2/token with the current refresh token.
  • Replaces the in-memory access token and persisted refresh token atomically.
  • Surfaces failure as a sign-in-required state so your UI can prompt re-authentication.

For server-side clients, do the same:

async function refreshAccessToken(oldRefresh: string) {
const res = await fetch(`${tenantUrl}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: oldRefresh,
client_id,
}),
})
if (!res.ok) {
const { error } = await res.json()
if (error === 'invalid_grant') {
// Rotation kicked in — the refresh token is dead.
// Either it was already used, or reuse was detected.
// Send the user back through sign-in.
throw new ReauthRequired()
}
throw new Error(`refresh failed: ${error}`)
}
return res.json() as Promise<TokenResponse>
}

The answer depends on the client type:

  • Browser SPA — the SDK keeps it in memory by default and refreshes silently via the tenant's session cookie. If you opt the SDK into cacheLocation: 'localstorage', the refresh token survives a tab close at the cost of XSS exposure. The default is the safe choice; flip it knowingly.
  • Backend — an encrypted column in your database, keyed to the user.
  • Native mobile — platform secure storage (Keychain on iOS, EncryptedSharedPreferences or Keystore on Android).
  • Desktop — OS keyring.

The general rule: the refresh token must not be readable by any code that can be tricked into exposing it. JavaScript that reads localStorage can be tricked. JavaScript that fetch()es with credentials: 'include' against an HttpOnly cookie cannot.

A refresh token lifetime is policy, not protocol. The application's settings page lets you set how long a refresh token lives — common values are 14 days for browser clients, 90 days for mobile clients, longer for desktop / CLI.

Whichever you pick, the user can be remotely signed out at any time by revoking the refresh token (see the revocation topic).

When the refresh token expires:

  • The next grant_type=refresh_token call returns invalid_grant.
  • The user is sent back through sign-in.
  • Sign-in may succeed silently (if the session cookie is still valid) or may require credentials again, depending on the tenant's session policy.

This is the rhythm of long-lived applications: cheap silent refresh while everything is fine, full sign-in at session boundary.

Every reuse detection lands in the audit feed with a distinctive event type. If you stream audit events to a security monitoring tool (a SIEM — Splunk, Datadog, Elastic, etc.), watch for spikes — a sudden jump in reuse-detection events across the tenant is a strong signal that refresh tokens are leaking somewhere.