Refresh Token Rotation: Long Sessions Without Long-Lived Risk
Short-lived access tokens limit damage if one leaks, but users don't want to sign in every 15 minutes. Refresh tokens bridge the gap, and rotation keeps them safe.
The idea
- Sign-in returns a short-lived access token (for example 15 minutes) and a refresh token.
- When the access token expires, the client sends the refresh token to get a new pair.
- Each refresh token works once. Using it returns a new refresh token and invalidates the old one.
- If an already-used refresh token is presented again, assume theft and revoke the whole family.
Storage
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
family_id UUID NOT NULL, -- all tokens from one sign-in
user_id BIGINT NOT NULL,
token_hash TEXT NOT NULL UNIQUE, -- store a hash, never the token itself
used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL
);
The refresh endpoint
app.post('/auth/refresh', async (req, res) => {
const presented = req.cookies.refresh_token;
const record = await db.refreshTokens.findByHash(sha256(presented));
if (!record || record.revoked_at || record.expires_at < new Date()) {
return res.status(401).json({ error: 'Please sign in again' });
}
if (record.used_at) {
// Reuse: someone has a copy. Revoke every token from this sign-in.
await db.refreshTokens.revokeFamily(record.family_id);
securityLog.warn({ userId: record.user_id }, 'refresh token reuse detected');
return res.status(401).json({ error: 'Please sign in again' });
}
await db.refreshTokens.markUsed(record.id);
const next = randomToken();
await db.refreshTokens.create({ familyId: record.family_id, userId: record.user_id, hash: sha256(next) });
res.cookie('refresh_token', next, { httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh' });
res.json({ accessToken: signAccessToken(record.user_id) });
});
Details that matter
- Mark-as-used and create-new must be atomic (one transaction) to avoid races.
- Allow a few seconds of grace for genuine concurrent refreshes from multiple tabs, or serialise refreshes on the client.
- Scope the cookie's
pathto the refresh endpoint so it isn't sent with every request. - Revoke all families on password change and on "sign out everywhere".
- Set an absolute session lifetime (for example 30 days) even with rotation.
Was this article helpful?
Comments
No comments yet. Have a question or a tip? Be the first to share it.