Short-lived, rotating tokens
15-minute access tokens; 7-day refresh tokens deleted on use.
From security-best-practices: JWT refresh rotation, OWASP A07.
Scrub the first hour
Drag the clock or press Play.
One 30-day tokenBad
Access token
A1If stolen now, works for
refresh_tokens0 rowsNothing to revoke.
jwt.sign({ userId }, SECRET, { expiresIn: '30d' })15m access, 7d refreshGood
Access token
A1If stolen now, works for
refresh_tokens| token | expiresAt | state |
|---|
expiresIn: '15m'expiresIn: '7d'// login
const accessToken = jwt.sign({ userId }, ACCESS_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, REFRESH_SECRET, { expiresIn: '7d' });
await db.refreshToken.create({
userId,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
Replay a stolen refresh token
Refresh as the client, then replay the stolen R1.
Verify, never deleteBad
Client holds
Attacker sessions0
refresh_tokensverifysign pairDelete on useGood
Client holds
Attacker sessions0
refresh_tokensverifydeletesign pairstoreapp.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
const payload = jwt.verify(refreshToken, REFRESH_SECRET);
// single use
const { count } = await db.refreshToken.deleteMany({ where: { token: refreshToken } });
if (count === 0) return res.status(401).json({ error: 'refresh token already used' });
const accessToken = jwt.sign({ userId: payload.userId }, ACCESS_SECRET, { expiresIn: '15m' });
const newRefreshToken = jwt.sign({ userId: payload.userId }, REFRESH_SECRET, { expiresIn: '7d' });
await db.refreshToken.create({
userId: payload.userId,
token: newRefreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
res.json({ accessToken, refreshToken: newRefreshToken });
});
How long a leak stays open
Set the lifetime and when the token leaks.
Attacker windowCompare
window = lifetime × (1 − leakedAt)Why it matters, pros and cons
Why it matters
An access JWT can't be revoked, so its lifetime is the damage window.
Pros
- A stolen access token dies within 15 minutes.
- Delete a user's rows to end every session.
- A replay is a 401 you can alert on.
Cons
- A database write per user every 15 minutes.
- If the attacker refreshes first, the real user is locked out.
- Two tabs refreshing at once race; one gets a 401.