Refresh Tokens Explained: Short-Lived Access, Long-Lived Sessions
Access tokens should expire quickly; refresh tokens let users stay logged in without re-entering passwords. How the pair works, where to store each, refresh token rotation and reuse detection, revocation, handling refresh in the front end, and when plain session cookies are simpler.
Token-based authentication has a tension built in:
- An access token is sent with every API request. If it's stolen, the thief can use it until it expires — so it should expire soon (minutes).
- But nobody wants to log in again every 15 minutes.
Refresh tokens resolve this. (What is a JWT?, API authentication methods)
The pair
| Access token | Refresh token | |
|---|---|---|
| Used for | Calling the API | Getting new access tokens |
| Sent to | Every API endpoint | Only the token endpoint |
| Lifetime | Minutes (5–60) | Days to months |
| Format | Often a JWT, checked without a database lookup | Usually an opaque random string stored server-side |
| If stolen | Limited damage window | Serious — must be protected and revocable |
The flow
- User logs in → server returns an access token and a refresh token.
- Client calls the API with the access token.
- Access token expires → API returns
401. - Client sends the refresh token to
/auth/refresh→ gets a new access token (and usually a new refresh token). - Repeat until the refresh token expires or is revoked → user logs in again.
Refresh token rotation and reuse detection
Best practice: issue a new refresh token on every refresh and invalidate the old one. Then, if an old refresh token is ever used again, that means it was copied — the server should revoke the whole session (the token "family"), forcing a fresh login.
async function refresh(presented: string) {
const record = await db.refreshToken.findUnique({ where: { hash: sha256(presented) } })
if (!record || record.expiresAt < new Date()) throw new Unauthorized()
if (record.usedAt) {
// reuse detected: someone has an old token
await db.refreshToken.updateMany({ where: { familyId: record.familyId }, data: { revokedAt: new Date() } })
throw new Unauthorized()
}
await db.refreshToken.update({ where: { id: record.id }, data: { usedAt: new Date() } })
const next = randomToken()
await db.refreshToken.create({
data: { hash: sha256(next), familyId: record.familyId, userId: record.userId, expiresAt: addDays(30) },
})
return { accessToken: signAccessToken(record.userId), refreshToken: next }
}
Store hashes of refresh tokens, not the tokens themselves, so a database leak doesn't hand out sessions. (Hashing vs encryption)
Handle the race where two tabs refresh at once — a short grace period for the just-rotated token avoids logging users out spuriously.
Where to store them
Web apps:
- Refresh token in an HttpOnly, Secure, SameSite cookie scoped to the refresh path — JavaScript can't read it, so XSS can't steal it. (Cookies explained)
- Access token in memory (a variable), not
localStorage. (localStorage vs cookies, XSS explained)
Mobile apps: the platform's secure storage (Keychain, Keystore).
Revocation
Because refresh tokens live in your database, you can revoke them: "log out of all devices", password change, suspicious activity, admin action. Access tokens (JWTs) generally can't be revoked before expiry without extra machinery — another reason to keep them short.
Front-end handling
Refresh when you get a 401, retry the original request once, and make sure only one refresh runs at a time:
let refreshing: Promise<void> | null = null
async function apiFetch(input: RequestInfo, init?: RequestInit) {
let res = await fetch(input, withAuth(init))
if (res.status !== 401) return res
refreshing ??= doRefresh().finally(() => { refreshing = null })
await refreshing
return fetch(input, withAuth(init))
}
Do you need any of this?
For a typical web app where the front end and back end share a domain, server-side sessions with an HttpOnly cookie are simpler: one opaque session ID, revocable instantly, no refresh dance. Access + refresh tokens earn their complexity with mobile apps, third-party API clients, multiple services, or OAuth integrations. (Session cookies vs JWTs)
And if you use an auth provider, it handles rotation for you. (Clerk vs Auth0 vs Supabase Auth)
EasySpawn gives your app a real backend and database on your own domain — the setup that makes simple, secure cookie sessions possible — with Claude Code to implement token rotation if you need it. See how it works or join the waitlist.
Related: What Is a JWT? · Session Cookies vs JWTs · OAuth PKCE Explained · API Authentication Methods
Keep reading
OAuth PKCE Explained: Why Every OAuth Flow Should Use It
PKCE (Proof Key for Code Exchange) protects the OAuth authorization code flow from code interception. How code_verifier and code_challenge work step by step, why it's required for SPAs and mobile apps and recommended for everyone, the state parameter, and the mistakes to avoid.
Next.js Server Actions: Forms and Mutations Without API Routes
Server Actions (Server Functions) let a form or button call a function that runs on the server, without writing an API route. How 'use server' works, forms with useActionState, validation with Zod, revalidating data, pending states — and why every action is a public endpoint that needs its own auth checks.