Blog
4 min read

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

  1. User logs in → server returns an access token and a refresh token.
  2. Client calls the API with the access token.
  3. Access token expires → API returns 401.
  4. Client sends the refresh token to /auth/refresh → gets a new access token (and usually a new refresh token).
  5. 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:

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))
}

(401 vs 403, Fetch vs Axios)

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