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.
The exchange
Section titled “The exchange”POST https://<your-tenant-url>/oauth2/tokenContent-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 desiredResponse:
{ "access_token": "...", "refresh_token": "...", // NEW — see "rotation" below "id_token": "...", "token_type": "Bearer", "expires_in": 3600}Rotation — the part that matters
Section titled “Rotation — the part that matters”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.
SDK handling
Section titled “SDK handling”If you use @intelliauth/react-sdk, you do not handle rotation directly. The SDK:
- Watches the access token's
expclaim. - About 60 seconds before expiry, calls
/oauth2/tokenwith 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>}Where to store the refresh token
Section titled “Where to store the refresh token”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.
Refresh-token lifetime
Section titled “Refresh-token lifetime”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).
What happens at the end of a refresh token's life
Section titled “What happens at the end of a refresh token's life”When the refresh token expires:
- The next
grant_type=refresh_tokencall returnsinvalid_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.
Reuse detection in the audit log
Section titled “Reuse detection in the audit log”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.