Short-lived access tokens are the standard advice, and they create an immediate problem: nobody wants to log in every fifteen minutes. Refresh tokens solve it — and introduce a longer-lived credential that's now the most valuable thing in your system.
Getting this right is mostly about what happens when a refresh token is used twice.
The two tokens
| Access token | Refresh token | |
|---|---|---|
| Lifetime | 10–15 minutes | 7–30 days |
| Sent with | every API request | only the refresh endpoint |
| Stored | memory or httpOnly cookie |
httpOnly cookie, and hashed in the DB |
| Verified by | signature alone | database lookup |
| Revocable | not until expiry | immediately |
That last row is the whole point. The access token is stateless and fast; the refresh token is stateful and revocable. You get low-latency auth checks and the ability to kill a session.
Scope the refresh cookie so it isn't sent on every request:
cookies().set('refresh_token', raw, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/auth/refresh', // ← only ever sent here
maxAge: 60 * 60 * 24 * 30,
});Rotation
Rotation means: every refresh consumes the old token and issues a new one. A refresh token is single-use.
export async function POST() {
const raw = (await cookies()).get('refresh_token')?.value;
if (!raw) return unauthorized();
const tokenHash = sha256(raw);
const stored = await RefreshToken.findOne({ tokenHash });
if (!stored || stored.expiresAt < new Date()) return unauthorized();
// ── reuse detection ─────────────────────────────────────
if (stored.usedAt) {
await RefreshToken.updateMany(
{ familyId: stored.familyId, revokedAt: null },
{ $set: { revokedAt: new Date(), revokedReason: 'reuse_detected' } }
);
return unauthorized();
}
// ────────────────────────────────────────────────────────
const next = crypto.randomBytes(48).toString('base64url');
await RefreshToken.updateOne({ _id: stored._id }, { $set: { usedAt: new Date() } });
await RefreshToken.create({
tokenHash: sha256(next),
userId: stored.userId,
familyId: stored.familyId, // same family — this is the chain
expiresAt: addDays(new Date(), 30),
});
const res = NextResponse.json({ accessToken: signAccessToken(stored.userId) });
res.cookies.set('refresh_token', next, REFRESH_COOKIE_OPTIONS);
return res;
}Why reuse detection matters
A refresh token is single-use, so a second use of the same token means one of two things: either it leaked and an attacker is using it, or it leaked and the legitimate user is using it after the attacker already did.
You can't tell which. So you assume the worst and revoke the entire family — every token descended from the same original login.
Walk through it:
- Attacker steals refresh token
R1. - Legitimate user refreshes first:
R1→R2.R1is now marked used. - Attacker presents
R1. It's used. → the whole family is revoked. - Both parties are logged out. The user re-authenticates; the attacker can't.
Or the other order:
- Attacker refreshes first:
R1→R2'. Attacker now has a working session. - User's app presents
R1. It's used. → family revoked, includingR2'. - Attacker's session dies too.
Either way the attacker gets, at most, one refresh window — and you get a signal. A spike in reuse_detected is one of the few high-quality intrusion indicators you can build cheaply.
The familyId is what makes step 3 work. Without it you'd only revoke one token and the attacker's chain would survive.
Store hashes, not tokens
Refresh tokens are bearer credentials. If your database leaks and they're in plaintext, every active session is compromised.
import crypto from 'node:crypto';
const sha256 = (value: string) => crypto.createHash('sha256').update(value).digest('hex');SHA-256 is right here — unlike passwords, these are 48 bytes of cryptographic randomness, so there's nothing to brute-force and no need for bcrypt's deliberate slowness. You're protecting against database disclosure, not guessing.
const refreshTokenSchema = new Schema({
tokenHash: { type: String, required: true, unique: true, index: true },
userId: { type: Schema.Types.ObjectId, required: true, index: true },
familyId: { type: String, required: true, index: true },
usedAt: { type: Date, default: null },
revokedAt: { type: Date, default: null },
revokedReason: { type: String },
userAgent: String,
ip: String,
expiresAt: { type: Date, required: true },
}, { timestamps: true });
// Let Mongo clean up expired rows for you
refreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });Recording userAgent and ip gives you a "active sessions" screen almost for free — and it's the kind of feature that makes an app feel finished.
The race condition everyone hits
This is the bug that turns a correct implementation into a support nightmare.
A page loads and fires four API calls in parallel. All four get a 401 because the access token just expired. All four call /refresh with the same refresh token. One succeeds; the other three look like reuse — and now the user is logged out for doing nothing wrong.
Two fixes, and you want both.
Client side: single-flight the refresh. Only one refresh may be in progress; everyone else waits on the same promise.
let refreshing: Promise<void> | null = null;
async function refreshOnce() {
refreshing ??= fetch('/api/auth/refresh', { method: 'POST' })
.then((res) => { if (!res.ok) throw new Error('refresh failed'); })
.finally(() => { refreshing = null; });
return refreshing;
}
export async function apiFetch(input: RequestInfo, init?: RequestInit) {
let res = await fetch(input, { ...init, credentials: 'include' });
if (res.status === 401) {
await refreshOnce(); // all callers share one refresh
res = await fetch(input, { ...init, credentials: 'include' });
}
return res;
}Server side: a small grace window. If a token was used seconds ago and its successor is still valid, treat it as a retry rather than an attack:
const GRACE_MS = 10_000;
if (stored.usedAt) {
const withinGrace = Date.now() - stored.usedAt.getTime() < GRACE_MS;
const successor = withinGrace
? await RefreshToken.findOne({ familyId: stored.familyId, usedAt: null, revokedAt: null })
: null;
if (!successor) {
await revokeFamily(stored.familyId, 'reuse_detected');
return unauthorized();
}
// Replay the successor instead of minting a new one
return respondWith(successor);
}Ten seconds is enough to absorb a burst of parallel requests and far too short for a stolen token to be useful. Skip this and you'll ship a login system that randomly logs people out under load — I have, and the bug report is very hard to reproduce.
Logout
Revoke the family, then clear the cookie:
export async function POST() {
const raw = (await cookies()).get('refresh_token')?.value;
if (raw) {
const stored = await RefreshToken.findOne({ tokenHash: sha256(raw) });
if (stored) await revokeFamily(stored.familyId, 'logout');
}
const res = NextResponse.json({ ok: true });
res.cookies.delete('refresh_token');
return res;
}Revoking only the current token leaves the rest of the chain alive. Revoke the family.
Also revoke every family for a user on password change — that's what "log me out everywhere" means, and users expect it after a compromise.
The checklist
- Access tokens 10–15 minutes, refresh tokens 7–30 days
- Refresh cookie is
httpOnly,secure,sameSite: strict, path-scoped - Only SHA-256 hashes stored, never raw tokens
- Every refresh rotates; the old token is marked used
- Reuse revokes the whole family
- A short grace window absorbs concurrent refreshes
- Client single-flights the refresh call
- TTL index cleans up expired rows
-
reuse_detectedevents are logged and alerted on
Rotation without reuse detection buys you very little. Reuse detection without a grace window ships a logout bug. You need the whole thing.