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.
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 token | Refresh token | |
|---|---|---|
| Sent to | Every API call | Only the authorization server's token endpoint |
| Lifetime | 5 to 60 minutes | Days to months |
| Format | Often a JWT, verified locally by APIs | Opaque random string, looked up by the auth server |
| Revocable | Not really, until it expires | Yes, it is a row in a database |
| If stolen | Attacker has minutes | Attacker 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#
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.
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.
// 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.
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#
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Access token in memory, refresh token in an HttpOnly cookie | JavaScript 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 storage | iOS 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 localStorage | Easy. | Any XSS reads both, and the refresh token gives the attacker access for weeks. | Avoid. |
In the interview#
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?
- 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.
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.