Skip to Content

Refresh Token Flow 🔄

Access tokens are short-lived on purpose — a leaked token expires quickly. The Refresh Token flow lets your application obtain a fresh access token without sending the user back through login, keeping sessions seamless while the tokens stay short-lived.

You receive a refresh token when a user authenticates with the Authorization Code flow or the Device Code flow. Store it, and exchange it whenever the access token expires.

[!TIP] If you use @faable/auth-js, you can skip this page entirely: the SDK refreshes tokens automatically in the background. Read on if you’re curious or implementing the flow yourself.


📸 How It Works

Every refresh returns a new refresh token (rotation). Always store the latest one and use it for the next refresh.


🚀 Automatic Refresh with @faable/auth-js

The SDK manages the whole lifecycle: it stores the session, runs a background ticker, and exchanges the refresh token before the access token expires — across tabs, transparently.

import { createClient } from '@faable/auth-js' const auth = createClient({ domain: 'your-domain.auth.faable.link', clientId: 'YOUR_CLIENT_ID' }) // That's it — sessions refresh automatically in the background. const { data } = await auth.getSession()

Forcing a Manual Refresh

Need a guaranteed-fresh token — for example right after a server-side change to the user’s claims? Call refreshSession():

const { data, error } = await auth.refreshSession() if (!error) { console.log('New access token:', data.session.access_token) }

🛠️ Refreshing over HTTP

Implementing it yourself (a backend, a CLI, a non-JS stack)? It’s a single request.

  • Endpoint: https://your-domain.auth.faable.link/oauth/token
  • Method: POST
  • Content-Type: application/x-www-form-urlencoded or application/json

Request Parameters

ParameterRequiredDescription
grant_typeYesMust be refresh_token.
client_idYesYour application’s Client ID.
refresh_tokenYesThe most recent refresh token you received.
scopeNoSpace-separated. Omit it to keep the original grant. May only narrow it — scopes not in the original grant are dropped (RFC 6749 §6).

The token’s audience is preserved automatically from the original grant — you don’t (and can’t) change it on refresh.

Example with curl

curl --request POST \ --url 'https://your-domain.auth.faable.link/oauth/token' \ --header 'content-type: application/x-www-form-urlencoded' \ --data 'grant_type=refresh_token' \ --data 'client_id=YOUR_CLIENT_ID' \ --data 'refresh_token=YOUR_REFRESH_TOKEN'

Response

{ "access_token": "eyJhbGciOiJSUzI1NiIs...", "id_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "v1.NEW_ROTATED_TOKEN...", "token_type": "Bearer", "expires_in": 86400, "scope": "openid profile email offline_access" }
FieldDescription
access_tokenFresh JWT, same aud as the original grant.
id_tokenRefreshed OIDC identity token. Keeps the original nonce, auth_time, and session id (sid) per the OIDC spec.
refresh_tokenA new, rotated refresh token. Replace the stored one with this value.
expires_inNew access token lifetime in seconds (from the target API’s token_lifetime, default 86400 = 24 h).
scopeThe scope granted to the new access token: the original grant, or the narrowed subset you requested.

⚠️ Common Errors

Errors use the standard OAuth 2.0 format: { "error": "...", "error_description": "..." }.

StatusErrorCause / Fix
400Missing refresh_tokenInclude the parameter in the request body.
400Bad decoded tokenThe value isn’t a valid refresh token. Check you’re not sending an access token by mistake.
400invalid_grantThe API (audience) the token was issued for no longer exists. Stop retrying and re-authenticate the user.
429Rate limit exceededYou’re refreshing too often. Refresh only when the access token is about to expire — or let @faable/auth-js schedule it for you.

[!IMPORTANT] Refresh only when needed — not on every request. The token endpoint is rate-limited per client, and a refresh loop (e.g. refreshing a 24-hour token every minute) will get throttled with 429 responses.


❓ FAQ

Where do I get a refresh token in the first place?

From a user login: the Authorization Code flow and the Device Code flow both return one alongside the access token.

Why is a new refresh token returned every time?

Rotation limits the damage of a leaked token: each refresh token is superseded by the next one. Always persist the latest value. For clients that went through a consent screen, a rotated token is also single-use — see OAuth 2.1.

Can I extend the scopes when refreshing?

No — RFC 6749 §6 only allows narrowing. Scopes not granted originally are silently dropped. To gain new scopes, send the user through login again.

Narrowing sticks: the rotated refresh token carries the narrowed scope, so a later refresh can’t widen it back.

Does a refreshed token keep its permissions?

Yes. The refreshed access token is computed from the same scope as the original one, so for an API with the access_token_authz dialect its permissions claim matches the original (or the narrowed subset). With RBAC enabled, roles are evaluated again on each refresh, so a role removed since login is no longer reflected in the new token.

Does the Client Credentials flow use refresh tokens?

No. Machine-to-machine clients simply request a new token with their credentials when the current one expires.

Do I have to implement any of this in a browser app?

No — use @faable/auth-js and it’s fully automatic, including multi-tab session sync.


Last updated on