Authentication And Identity Systems
Access Tokens, Refresh Tokens, And Expiring Tokens
The Two-Token Model
An access token is short-lived (minutes) and sent on every API request. A refresh token is long-lived (days or weeks), sent only to a single token-refresh endpoint, and used to obtain fresh access tokens when the current one expires.
<?php
declare(strict_types=1);
$tokens = [
'access token' => ['lifetime' => '15 minutes', 'sent' => 'every request', 'stored' => 'memory'],
'refresh token' => ['lifetime' => '30 days', 'sent' => 'refresh endpoint only', 'stored' => 'httpOnly cookie'],
];
foreach ($tokens as $kind => $props) {
echo $kind . ': ' . $props['lifetime'] . ', sent on ' . $props['sent'] . PHP_EOL;
}
// Prints:
// access token: 15 minutes, sent on every request
// refresh token: 30 days, sent on refresh endpoint only
The payoff is asymmetric exposure. The access token is exposed constantly (every request) but is nearly worthless once it expires minutes later. The refresh token is powerful but rarely transmitted and can be stored more defensively. A stolen access token buys the attacker minutes; a stolen refresh token is the real prize, which is why the rest of this lesson is mostly about protecting and revoking it.
Stateless Versus Revocable
Access tokens are often JWTs — self-contained, signed, and validated without a database lookup, which is what makes them scale. That same statelessness is their weakness: a signed JWT is valid until it expires, and there is no clean way to revoke one mid-life, because nothing is checked against a server record. This is precisely why access token lifetimes are short — the expiry is the revocation mechanism. (The mechanics of signing and validating JWTs live in the token implementation lesson.)
Refresh tokens take the opposite approach: they are stored server-side (or a hash of them is), so they can be revoked instantly by deleting the record. Logout, "sign out everywhere", password change, and suspected compromise all work by invalidating refresh tokens. The division of labor is the whole design: stateless access tokens for scale, stateful refresh tokens for control.
Rotation And Theft Detection
The strongest refinement is refresh token rotation: each time a refresh token is used, it is invalidated and a new one issued. This shrinks a refresh token's useful life to a single use and enables theft detection. If a refresh token is ever presented twice — once by the legitimate client, once by a thief who copied it — the server sees a already-used token and can conclude the token was stolen. The correct response is to revoke the entire token family (that user's whole session lineage), forcing re-authentication and locking out the attacker. This turns refresh token theft from an undetectable compromise into a self-tripping alarm.
Expiry Everywhere
The expiring-token pattern generalizes well beyond login. Password reset links, email verification links, magic links, and API keys all benefit from a lifetime and, where the stakes are high, single use. The rule of thumb: the more powerful the action a token authorizes, the shorter it should live and the more it should be single-use. A password-reset token grants full account control, so it should expire in minutes and die on first use — the exact opposite of the "six-digit code, no expiry" antipattern.
What To Check
Before moving on, make sure you can:
- explain the access/refresh split and the exposure asymmetry it exploits
- state why stateless JWTs cannot be revoked and why that forces short lifetimes
- explain how server-stored refresh tokens enable instant revocation
- describe refresh token rotation and how reuse detects theft
- match token lifetime and single-use to the power of the action authorized
What You Should Be Able To Do
After this lesson, you should be able to design a token scheme that balances scale against revocability, protect and rotate refresh tokens, detect their theft through reuse, and apply appropriate expiry to every kind of token your system issues.
Practice
Practice: Design A Token Scheme
Show solution
Storage per client: the web frontend keeps the access token in memory (not localStorage, to limit XSS theft) and the refresh token in an HttpOnly, Secure, SameSite cookie scoped to the refresh endpoint, so JavaScript cannot read it. The mobile app keeps the refresh token in the platform secure storage (Keychain/Keystore). The access token being in memory means it dies on tab close, which is fine because refresh restores it.
Logout: delete the refresh token record server-side; the access token is left to expire within minutes. "Sign out everywhere": delete all refresh token records for the user — the stateful refresh store is what makes this a one-query operation.
Theft detection: refresh tokens rotate on every use. The server stores the token family lineage; if a refresh token is presented after it has already been rotated away, that is reuse, which means a copy exists. Response: revoke the entire family, force re-authentication, and alert per the application-monitoring lesson. Password change also revokes all families.
The result: constant-exposure access tokens are cheap to steal and nearly worthless; the valuable refresh tokens are rarely transmitted, unreadable by script, individually revocable, and self-alarming on theft.
Practice: Diagnose An Unrevocable Session
An app issues a single long-lived (30-day) JWT access token, stored in localStorage, checked only by signature and expiry. A user reports their laptop stolen and asks to be logged out everywhere. Support discovers they cannot actually revoke the token, and separately, an XSS bug is found that could read localStorage.
Task
Explain both problems and redesign the scheme.
Show solution
Two design errors combine into an unrecoverable situation.
Unrevocable sessions: a stateless JWT is valid on signature and expiry alone, checked against no server record, so there is nothing to delete to invalidate it. The 30-day lifetime means the stolen laptop's token works for up to a month and support is telling the truth that they cannot kill it. Statelessness bought scale at the cost of revocability, and a 30-day lifetime maximized the damage of that trade — the expiry is supposed to be the revocation window, and 30 days is far too long for a token that cannot otherwise be revoked.
XSS exposure: storing the token in localStorage makes it readable by any script that runs on the page, so the XSS bug is a direct token-theft primitive. An attacker exfiltrates the token and has a month of access.
Redesign: split into short-lived access + revocable refresh. Access token JWT at ~15 minutes, held in memory (not localStorage), so XSS has a much smaller window and no persistent store to loot. Refresh token stored server-side and sent only via an HttpOnly cookie unreadable by script. Now "log out everywhere" deletes the user's refresh records and the access tokens expire within minutes — the stolen laptop is locked out in a quarter hour. Add rotation so a copied refresh token trips the reuse alarm. The two changes address the two failures: revocability via the stateful refresh store, and theft-resistance via memory + HttpOnly storage.
Practice: Define Token Scheme Checks
Show solution
- Tests assert access token lifetime is short (minutes, not days) and that an expired access token is rejected.
- A test performs logout and "sign out everywhere" and asserts the refresh token(s) no longer work, proving revocability actually functions rather than being assumed.
- A rotation test uses a refresh token twice and asserts the second use fails and revokes the family — the theft-detection invariant, tested directly.
- A test confirms refresh tokens are stored hashed server-side, and that the web client delivers them only via an
HttpOnlycookie (no token in localStorage/response body for the browser client). - A password-change test asserts all of that user's refresh tokens are invalidated.
Human, periodically:
- Review token lifetimes against current risk appetite — lifetimes creep upward for convenience and shorten the revocation guarantee.
- Confirm the theft-detection alarm routes somewhere read, connecting to the application-monitoring lesson.
- Check that new token types added since last review (a new API-key feature, a new mobile flow) inherited expiry and revocation rather than reinventing an unrevocable token.