Access and Refresh Tokens
Short-lived access, long-lived refresh, rotation on every use, and reuse detection that catches a theft.
Short access tokens are safer; making users log in every ten minutes is unacceptable. The refresh token resolves that tension: a long-lived credential whose only power is minting new access tokens.
The two tokens
| Access token | Refresh token | |
|---|---|---|
| Lifetime | 5–15 minutes | days to months |
| Sent to | every API call | only the refresh endpoint |
| Format | usually a JWT | usually opaque + stored |
| Stored | memory | HttpOnly cookie / keychain |
| Revocable | not really | yes — delete the row |
Rotation
Every refresh invalidates the old refresh token and issues a new one. A stolen refresh token is then useful only until the legitimate client refreshes next — typically minutes.
Reuse detection: the clever part
Because tokens rotate, a token being presented twice means something is wrong: either an attacker is using a copy, or the legitimate client is. You cannot tell which — so revoke the whole family (every descendant of the original login) and force a fresh login. One legitimate user is inconvenienced; a thief is locked out completely.
Where to keep it in a browser
An HttpOnly, Secure, SameSite=Strict cookie scoped to Path=/auth/refresh. JavaScript cannot read it, so XSS cannot steal it; it is only attached to the one endpoint that needs it; and since that endpoint does nothing but rotate, the CSRF risk is small — though a CSRF token there is still cheap insurance.
Mobile and native
iOS Keychain, Android Keystore, or an OS credential store. Not a plain file, not AsyncStorage, not a preferences plist.
Also expire the family
Rotation alone lets a session live forever. Give each family an absolute lifetime (say 30 days from the original login) so a stolen-and-quietly-rotated token still dies.
Example
# Login → access (10 min) + refresh (30 days, rotating)
POST /auth/login → { accessToken, expiresIn: 600 }
Set-Cookie: rt=RT1; HttpOnly; Secure;
SameSite=Strict; Path=/auth/refresh
# 10 minutes later
POST /auth/refresh → { accessToken, expiresIn: 600 }
Cookie: rt=RT1 Set-Cookie: rt=RT2 ← RT1 is now dead
# An attacker who copied RT1 tries to use it
POST /auth/refresh → 401 { "error": "token_reuse_detected" }
Cookie: rt=RT1 ...and RT2, RT3 and the whole family are revoked.
Both parties must log in again — which is the point.When to use it
- A stolen refresh token is detected the moment the real user refreshes, and the whole family is revoked before the attacker can do anything with it.
- A mobile app keeps users signed in for 30 days while every API call carries a token that expires in ten minutes.
- A support tool revokes one refresh family to sign a specific device out without disturbing the user's other devices.
More examples
Rotation with reuse detection
familyExpiresAt is copied forward rather than extended, so rotation cannot keep a session alive past its absolute 30-day cap.
import { randomBytes, createHash } from 'crypto';
const hash = (t) => createHash('sha256').update(t).digest('hex');
const REFRESH_TTL_MS = 30 * 24 * 3600e3;
app.post('/auth/refresh', async (req, res) => {
const presented = req.cookies.rt;
if (!presented) return res.status(401).json({ error: 'no_refresh_token' });
const row = await db.refreshTokens.findByHash(hash(presented));
if (!row) return res.status(401).json({ error: 'invalid_refresh_token' });
// --- reuse detection -------------------------------------------------
// The row exists but was already rotated → someone is replaying a copy.
if (row.usedAt) {
await db.refreshTokens.revokeFamily(row.familyId);
clearRefreshCookie(res);
return res.status(401).json({ error: 'token_reuse_detected' });
}
if (row.revokedAt || row.familyExpiresAt < new Date()) {
clearRefreshCookie(res);
return res.status(401).json({ error: 'refresh_token_expired' });
}
// --- rotate ----------------------------------------------------------
const next = randomBytes(32).toString('base64url');
await db.transaction(async (tx) => {
await tx.refreshTokens.markUsed(row.id); // old one is now spent
await tx.refreshTokens.insert({
hash: hash(next),
userId: row.userId,
familyId: row.familyId, // same family
familyExpiresAt: row.familyExpiresAt, // absolute cap survives
parentId: row.id,
});
});
res.cookie('rt', next, {
httpOnly: true, secure: true, sameSite: 'strict',
path: '/auth/refresh', maxAge: REFRESH_TTL_MS,
});
res.json({ accessToken: issueAccessToken(row.userId), expiresIn: 600 });
});
function clearRefreshCookie(res) {
res.clearCookie('rt', { path: '/auth/refresh', secure: true, sameSite: 'strict' });
}The table this needs
family_id is what makes 'sign out this device' and 'sign out everywhere' two lines apart instead of two features apart.
<?php
// database/migrations/xxxx_create_refresh_tokens_table.php
Schema::create('refresh_tokens', function (Blueprint $table) {
$table->id();
$table->string('hash', 64)->unique(); // sha256 hex — never the token
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->uuid('family_id')->index(); // all descendants of one login
$table->foreignId('parent_id')->nullable();
$table->timestamp('used_at')->nullable(); // set on rotation → reuse signal
$table->timestamp('revoked_at')->nullable();
$table->timestamp('family_expires_at'); // absolute cap on the session
$table->string('user_agent')->nullable(); // for the "active devices" screen
$table->ipAddress('ip')->nullable();
$table->timestamps();
});
// Revoking one device
DB::table('refresh_tokens')->where('family_id', $familyId)
->update(['revoked_at' => now()]);
// Revoking everything (password change, account compromise)
DB::table('refresh_tokens')->where('user_id', $userId)
->update(['revoked_at' => now()]);
Discussion