Access tokens and refresh tokens: why one expires in fifteen minutes
A bearer token can't be recalled, so the only way to bound the damage is to bound the time. Everything else — the split, the 401, the rotation — falls out of that one sentence.
Your access token expired an hour ago. You are still logged in, and nothing on the screen moved when it happened.
That is the whole subject. Not “short tokens are more secure” — that’s the slogan, and it explains nothing. The real answer is one sentence: a bearer token cannot be recalled, so the only way to bound the damage is to bound the time, and the only way to make a short life survivable is to automate the renewal. Two tokens, because one credential cannot be both short-lived and the thing that survives.
A bearer token doesn’t care who’s holding it
RFC 6750 defines it about as bluntly as a spec can:
A security token with the property that any party in possession of the token (a “bearer”) can use the token in any way that any other party in possession of it can. Using a bearer token does not require a bearer to prove possession of cryptographic key material (proof-of-possession).
No password. No second factor. No challenge. The request carries the string, and the string is the authorisation:
GET /v1/photos HTTP/1.1
Host: api.photos.com
Authorization: Bearer 2YotnFZFEjr1zCsicMWpAA
So a leaked access token isn’t a lead to follow. For as long as it’s valid, it is the account.
The obvious fix is to cancel it. And that is where the design gets interesting, because cancelling means asking somewhere whether this token is still good — on every single request. That’s a lookup against server state, which is precisely the cost a self-contained token exists to avoid. (If that trade sounds familiar, it’s the one in Sessions vs JWT: a pointer you have to dereference, versus a copy you can verify alone.)
You can have a token the server can kill instantly, or a token the server never has to look up. Picking the second one is picking to lose the kill switch — and buying it back with a clock.
The split: one token travels, one doesn’t
Splitting the credential in two is what makes a fifteen-minute lifetime livable. The important asymmetry isn’t the clock, though. It’s the address list.
RFC 6749 §1.5 is explicit about it:
Unlike access tokens, refresh tokens are intended for use only with authorization servers and are never sent to resource servers.
| Access token | Refresh token | |
|---|---|---|
| Sent to | every resource server you call | the authorization server’s token endpoint, and nowhere else |
| How often | every request | once per renewal |
| Lifetime | minutes | hours to months |
| Client auth to use it | none — it’s a bearer token | required for confidential clients (§6) |
Read the first row twice. The token that touches the most surface area — every
API, every log, every proxy, every third-party service you were told was fine —
is the one that dies fastest. The token with the long life visits exactly one
endpoint, over TLS. The clock follows the exposure. That’s the design.
expires_in is a value the provider chooses, not a spec — RFC 6749’s own
examples use 3600, and fifteen minutes is a plausible number, not a rule.
Renewing, on the wire
Nothing here is magic, and it’s worth seeing because it’s three HTTP messages.
The access token runs out, and the resource server answers per RFC 6750 §3.1:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="example",
error="invalid_token",
error_description="The access token expired"
The client goes to the token endpoint with the refresh token — both parameters REQUIRED, and a confidential client MUST authenticate (RFC 6749 §6):
POST /token HTTP/1.1
Host: photos.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA
A fresh access token comes back, the client retries the original request, and the user saw none of it.
Two details worth knowing. The requested scope on that renewal “MUST NOT include any scope not originally granted” (§6) — a renewal is not a second consent screen, it can only narrow. And if the response includes a new refresh token, “the client MUST discard the old refresh token and replace it with the new” — storing both is how you trip the replay alarm on yourself.
Expiry isn’t revocation
This is the conflation the whole topic invites. They are different events with different mechanisms.
Expiry is a clock the token carries, decided at issue time, that fires whether or not anyone is watching. Revocation is a decision someone makes now — a user hitting “disconnect”, a password change. RFC 7009 gives it its own endpoint, and revoking a refresh token typically takes the access tokens issued under it with it, if the server supports that.
But “revoke the refresh token” stops the renewals. The access token already
in the wild keeps working until its own clock runs out, and that window — up to
expires_in — is exactly the price you agreed to pay by choosing a token nobody
has to look up. RFC 7009 §3 says the fix depends on what kind of token you
issued: a handle the authorization server can look up is revocable instantly;
a self-contained one needs “some (currently non-standardized) backend
interaction” — or introspection, RFC 7662, where the resource server posts
the token to the authorization server and gets back active: true/false on
every request. That buys back the kill switch at the price of a network round
trip per call — the same trade a session makes, wearing a token’s clothes. And
if you cache that response to save the round trip, RFC 7662 §4 is upfront about
what it costs: “a revoked token could be used” for the life of the cache. There
is no configuration where the window disappears and the round trip does too.
Rotation detects theft; it doesn’t prevent it
“We rotate refresh tokens” gets said as though it ends the conversation. It doesn’t.
Rotation (RFC 9700 §4.14.2): the authorization server issues a new refresh token with every renewal and invalidates the previous one. If a stolen token gets used by both the attacker and the legitimate client, one of them presents an already-invalidated token — the server can’t tell which one was the thief, but it revokes the active token and both parties are logged out. That’s theft detected, after the fact, on the condition that both parties actually use the token. If the attacker takes it and the real user never opens the app again, nothing trips.
Sender-constraining is a different category of answer: bind the token to a key the legitimate client holds, so presenting the string isn’t enough — you have to prove possession of the key. RFC 9449, DPoP, does this with a signed JWT per request, and states the difference from rotation directly: “If the private key is non-extractable, DPoP renders exfiltrated tokens alone unusable.” Not detectable — unusable. RFC 9700 §2.2.2 requires it (or rotation) for public clients specifically, because a confidential client’s refresh token is already bound to credentials only its server holds.
The one-line version: rotation is a smoke alarm, sender-constraining is a lock. Most deployments today have the alarm.
Three mistakes I see constantly
- Treating rotation as theft prevention. It’s detection, and only if both the real client and the attacker use the token before you notice.
- Refreshing on a timer instead of on a 401. A timer guesses a number the
provider owns and can change. A 401 with
invalid_tokenis the provider telling you — and make the retry idempotent, or ten requests failing at once produce ten refreshes instead of one, burning your own token on a rotating provider. - Revoking the access token and calling it done. The refresh token is what keeps minting new ones. Revoke that; the access token dies on its own clock, and that gap is a decision you should be able to state out loud.
Test Your Understanding
Quick architectural scenario challenge based on this breakdown.
A 3rd-party payment gateway starts timing out with 504 errors during peak traffic.
Q1.How should a downstream dependency failure be handled to protect upstream service availability?
Comments –
Sign in to share your thoughts
Nothing yet. Be the first.