Library
9 min read

Access tokens vs refresh tokens

Published

A hotel gives you a key card that opens your room until checkout. Lose it on day one and a thief has your room for a week. A better hotel gives you a card that works for one day, and a receipt you keep in the safe. Each morning you show the receipt at the front desk and get a new card. A thief who grabs today's card gets one day, and the receipt never leaves the room.

The key card is an access token. The receipt is a refresh token. That is the entire idea: one token that is used constantly and lives briefly, and one that is used rarely, lives long and can be cancelled.

Your answer

If access tokens are so risky that they expire every 15 minutes, why is it safe to have a refresh token that lasts 30 days? What makes it less risky, not more?

Two tokens, two jobs#

Access tokenRefresh token
Sent toEvery API callOnly the authorization server's token endpoint
Lifetime5 to 60 minutesDays to months
FormatOften a JWT, verified locally by APIsOpaque random string, looked up by the auth server
RevocableNot really, until it expiresYes, it is a row in a database
If stolenAttacker has minutesAttacker can mint new access tokens, unless rotation catches it

The refresh token's safety comes from where it goes, not from being stronger. An access token is attached to hundreds of requests, passes through API gateways, logs, proxies and every microservice. A refresh token is sent to exactly one endpoint, rarely. Less exposure, and when something does go wrong, one database row to delete.

The refresh dance#

1/9 A1 is a JWT issued 14 minutes ago with a 15 minute life.

Good clients refresh a little before expiry rather than waiting for the 401, which saves a failed round trip. They still handle the 401, because clocks drift and tokens get revoked.

Rotation and reuse detection#

Rotation means every refresh returns a new refresh token and kills the old one. That alone does not stop a thief who uses the stolen token first. What makes it powerful is reuse detection: the server remembers the whole chain of tokens it issued to one login, and if any old, already spent token shows up again, someone has a copy. The server does not know which side is the thief, so it revokes the entire chain and both have to log in again. The attacker is out; the real user is mildly annoyed.

This is the recommendation in the IETF's OAuth security best practice (RFC 9700, published January 2025) for public clients like SPAs and mobile apps, where the refresh token cannot be bound to a client secret.

The multi tab bug

Rotation plus reuse detection has a classic failure. A user has your app open in three tabs. The access token expires, and all three tabs refresh at the same moment with the same refresh token R1. The first succeeds and gets R2. The other two present R1, which is now spent, so the server sees reuse, revokes everything, and logs the user out of all three tabs.

Fixes: coordinate refreshes across tabs so only one runs (a lock via the Web Locks API or a BroadcastChannel), and give the server a short grace window, a few seconds, in which the previous token is still accepted from the same client.

ts
// Every caller that hits a 401 at the same time waits on the same promise,
// so only one refresh request ever goes out from this tab.
let inflight: Promise<string> | null = null
 
export function refreshAccessToken(): Promise<string> {
  if (!inflight) {
    inflight = fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
      .then((r) => {
        if (!r.ok) throw new Error('session ended')
        return r.json()
      })
      .then((body) => body.accessToken)
      .finally(() => {
        inflight = null
      })
  }
  return inflight
}

Picking lifetimes#

The access token lifetime is your worst case for revocation and your refresh load at the same time. Shorter is safer and costs more trips to the auth server. Put numbers on it.

Refresh load on the auth server
500 thousand
15 min
Each active user refreshes once per lifetime500K / (15 x 60 s) = 556 refreshes / s
Worst case access after a revoke15 min
556 / s
refresh requests, each a database read and write

This is the usual range. A few thousand refreshes a second is ordinary work for a database backed token service, and revocation lags by minutes, not hours.

For refresh tokens, two clocks matter. An idle timeout (say 14 days without use) logs out abandoned devices. An absolute lifetime (say 90 days) forces a real login now and then, even for active users, which is when MFA gets asked again. Banking apps use much shorter values than social apps, and they should.

Where to keep them#

ChoiceWhat you gainWhat you payPick it when
Access token in memory, refresh token in an HttpOnly cookieJavaScript cannot read the refresh token, so XSS cannot steal it. The access token is gone on reload and silently refreshed.The cookie needs SameSite and a path limited to the refresh endpoint.SPAs talking to their own domain.
Backend for frontend (BFF)Tokens never reach the browser. It holds only a session cookie, and a small server attaches tokens to API calls.You run a backend, even for a "pure" SPA.The current recommendation for browser apps with anything sensitive.
Mobile: OS secure storageiOS Keychain and Android Keystore protect the refresh token on the device.None worth naming. Never put it in plain shared preferences.Native apps.
Both in localStorageEasy.Any XSS reads both, and the refresh token gives the attacker access for weeks.Avoid.

In the interview#

Interviewer
Why not just issue one long lived access token and skip refresh tokens?
A probe for whether you understand exposure, not just the mechanics.
You
Because the access token goes everywhere: every request, every service, every log line that captures headers. If it lasts a month, any leak lasts a month and I cannot revoke it without a lookup on every call. Splitting them gives me a token that is widely exposed but short lived, and one that is long lived but only ever sent to the auth server, where it is checked against the database. That check is where I enforce logout, password resets and disabled accounts.
Exposure versus lifetime, and the database check as the revocation point.
Interviewer
An attacker steals a refresh token from a phone. What happens?
You
With rotation and reuse detection, the next time either the attacker or the real app uses an old token, the server sees a spent token reused and revokes the whole family. The damage window is until the real device next refreshes, typically minutes. Without rotation, the attacker has it until it expires or someone notices.
Access or refresh?
Sent in the Authorization header to /api/orders
Deleted from the database when the user clicks "log out of all devices"
Usually a JWT
Should be bound to one client and rotated on every use

Checkpoint#

Checkpoint

1. Why is a 30 day refresh token acceptable when access tokens last 15 minutes?

2. Under rotation with reuse detection, an already used refresh token is presented again. What does the server do?

3. Users get logged out randomly when they have several tabs open. The likely cause?

Worth memorising
  • Access token: short, sent everywhere, verified locally. 5 to 15 minutes is typical.
  • Refresh token: long, sent only to the auth server, revocable.
  • Rotate refresh tokens and detect reuse. Revoke the whole family on reuse.
  • Refresh rate = active users / access lifetime. Size the auth server for it.
  • In browsers: BFF, or access in memory plus refresh in an HttpOnly cookie. Never both in localStorage.
Say this in 60 seconds

OAuth issues two tokens because they have different exposure. The access token goes on every API call, through gateways and logs, so it is short lived, typically five to fifteen minutes, and usually a JWT that APIs verify locally. The refresh token is long lived but is only ever sent to the auth server, which looks it up in a database on each use, so that is where I enforce logout, disabled accounts and password changes. I rotate refresh tokens on every use and detect reuse: if a spent token shows up, I revoke the whole family. The auth server's load is roughly active users divided by access token lifetime. In the browser I prefer a backend for frontend so tokens never reach JavaScript, or at least the access token in memory and the refresh token in an HttpOnly cookie. And I guard against the multi tab race by making refresh single flight.