Token refresh

Silent refresh on 401.

7 minmedium

Access tokens expire after 15 minutes. Without refresh, the user gets logged out every 15 minutes β€” terrible UX. With refresh, they're logged in for a week. This lesson covers the pattern.

The flow

Silent refresh sequence

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Mobile β”‚ β”‚ API β”‚ β”‚SecureStoreβ”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ GET /api/users + access β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ίβ”‚ β”‚ β”‚ 401 Unauthorized β”‚ β”‚ │◄──────────────────────────── β”‚ β”‚ β”‚ β”‚ β”‚ read refresh ─────────────┼───────────────────────────►│ │◄────────────────────── refresh value ─────────────────── β”‚ β”‚ β”‚ β”‚ POST /api/auth/refresh β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ίβ”‚ β”‚ β”‚ 200 + new access+refresh β”‚ β”‚ │◄──────────────────────────── β”‚ β”‚ β”‚ β”‚ β”‚ save new tokens ──────────┼───────────────────────────►│ β”‚ β”‚ β”‚ β”‚ GET /api/users + new access β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ίβ”‚ β”‚ β”‚ 200 OK + data β”‚ β”‚ │◄──────────────────────────── β”‚
The whole dance happens silently. The user sees no interruption.

The whole dance happens silently. The user doesn't see a thing.

The fetch interceptor pattern

apps/mobile/lib/api.ts (refresh-aware version)
async function authedRequest<T>(method: string, path: string, body?: unknown): Promise<T> {
let { access, refresh } = await loadTokens()
const doFetch = (token: string) => fetch(`${API_URL}${path}`, {
method,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: body ? JSON.stringify(body) : undefined,
})
let res = await doFetch(access!)
// If access token is dead, try to refresh once
if (res.status === 401 && refresh) {
const r = await fetch(`${API_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refresh }),
})
if (r.ok) {
const json = await r.json()
await saveTokens(json.data.access_token, json.data.refresh_token)
res = await doFetch(json.data.access_token)
}
}
const json = await res.json()
if (!res.ok) throw new ApiError(json.error?.code, json.error?.message, res.status)
return json as T
}

One interceptor, handles every authed call. If refresh succeeds, retry; if it fails, the next 401 propagates and the AuthProvider kicks the user to /login.

Single-flight: don't refresh 10x in parallel

If 10 React Query queries all fire simultaneously and all return 401, you don't want 10 refresh calls β€” that creates 10 new access tokens and 9 are wasted (and refresh-token rotation makes 9 of them stale). Use a promise lock:

let refreshing: Promise<{ access: string; refresh: string }> | null = null
async function refreshTokens() {
if (refreshing) return refreshing
refreshing = doRefresh().finally(() => { refreshing = null })
return refreshing
}

Every concurrent call hits the same promise β€” one network refresh, all callers get the new token.

Refresh tokens rotate. Every successful refresh returns a NEW refresh token; the old one is invalidated server-side. Save the new one immediately. If you forget, the next refresh fails and the user is logged out.

What happens when refresh fails

Two cases:

  • Network error β€” show a toast, let the user retry. Don't log them out.
  • 401 from /refresh β€” refresh token is expired or revoked. Clear tokens, send to /login.

React Query plays nicely

React Query's built-in retry kicks in if a query throws. With the interceptor doing the refresh, you usually want retry: false on auth-required queries β€” the interceptor already retried; a second retry is wasted.

Quick check

Your user has been on the app for 30 minutes. They open a new screen that fires 5 API calls in parallel. All 5 return 401. What's the expected behaviour?

Try it

Verify your refresh works end-to-end:

  1. Set your API's JWT_ACCESS_EXPIRY to 30s for testing (in .env).
  2. Restart the API.
  3. Sign in on the mobile app.
  4. Wait 35 seconds. Then tap something that fires a query.
  5. The query should silently refresh + retry; you see data, not a login screen.
  6. Open Metro's network inspector β€” you should see one POST to /api/auth/refresh sandwiched between the two attempts.

Paste the network inspector output in notes.md. Don't forget to revert JWT_ACCESS_EXPIRY to 15m after.

What's next

Chapter 4 β€” Push notifications. Register for push, save the token to your Grit API, send a push from a job worker.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled β€” suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub