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-urlencodedorapplication/json
Request Parameters
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | Must be refresh_token. |
client_id | Yes | Your application’s Client ID. |
refresh_token | Yes | The most recent refresh token you received. |
scope | No | Space-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"
}| Field | Description |
|---|---|
access_token | Fresh JWT, same aud as the original grant. |
id_token | Refreshed OIDC identity token. Keeps the original nonce, auth_time, and session id (sid) per the OIDC spec. |
refresh_token | A new, rotated refresh token. Replace the stored one with this value. |
expires_in | New access token lifetime in seconds (from the target API’s token_lifetime, default 86400 = 24 h). |
scope | The 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": "..." }.
| Status | Error | Cause / Fix |
|---|---|---|
400 | Missing refresh_token | Include the parameter in the request body. |
400 | Bad decoded token | The value isn’t a valid refresh token. Check you’re not sending an access token by mistake. |
400 | invalid_grant | The API (audience) the token was issued for no longer exists. Stop retrying and re-authenticate the user. |
429 | Rate limit exceeded | You’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
429responses.
❓ 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.
🔗 Related
- @faable/auth-js — official SDK with automatic token refresh built in.
- Authorization Code Flow — where refresh tokens are first obtained.
- Device Code Flow — login for CLIs and browser-less devices, also returns a refresh token.
- Clients — application configuration.
- RFC 6749 §6 — Refreshing an Access Token — the official standard.
Last updated on