Race Conditions in JWT Refresh Token Rotation
Modern web apps often use JWTs for stateless authentication. Access tokens have short lifetimes (minutes) while refresh tokens live longer (hours or days). To keep users logged in securely, you rotate (issue a new) refresh token on each use. The Race Condition Imagine two almost-simultaneous API calls: Client calls /api/protected with an expired access token. Server sees the refresh token is still valid, issues a new access token and a new refresh token. Client receives the new tokens, stores them. But—what if two identical requests raced to that refresh endpoint? Both see the old refresh token as valid, both get back tokens, but only the last one is truly valid server-side. The other “wins” on the token store and invalidates its peer. The client’s second response writes over the first, and whichever response arrives later will overwrite the client’s stored refresh token. If that one is the stale, already-revoked token, the next refresh fails and the user is logged out. Why It Matters User Friction: Unexpected logouts for users who simply “clicked twice” Edge Bugs: Hard to reproduce, intermittent Security: Orphaned refresh tokens can linger or be replayed A Simple Mitigation: Skew Your Cookie Expiry Instead of setting your refresh‐token cookie’s Expires exactly to match the JWT’s exp claim, subtract a small delta—say, 30 seconds: const REFRESH_TOKEN_LIFETIME = 60 * 60; // e.g. 1 hour in seconds const COOKIE_SKEW_SECONDS = 30; // safety buffer const refreshTokenExp = Date.now() + REFRESH_TOKEN_LIFETIME * 1000; res.cookie('refresh_token', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'Strict', expires: new Date(refreshTokenExp - COOKIE_SKEW_SECONDS * 1000), }); The browser will stop sending the cookie ~30 s before the server considers the token truly expired. It prevents the race where one “losing” request arrives after the cookie has already been dropped, so it never attempts to rotate again with a stale token. Picking the Right Delta 30 s–2 m buffer is usually plenty. Longer if your network is high-latency or clients might queue multiple requests offline. other posts: Practicing politeness in javascript code Timing attacks in node.js Express.js honeypot Legendary emails in node.js with MJML Let's connect!!:

Modern web apps often use JWTs for stateless authentication.
Access tokens have short lifetimes (minutes) while refresh tokens live longer (hours or days). To keep users logged in securely, you rotate (issue a new) refresh token on each use.
The Race Condition
Imagine two almost-simultaneous API calls:
- Client calls /api/protected with an expired access token.
- Server sees the refresh token is still valid, issues a new access token and a new refresh token.
- Client receives the new tokens, stores them.
But—what if two identical requests raced to that refresh endpoint?
Both see the old refresh token as valid, both get back tokens, but only the last one is truly valid server-side. The other “wins” on the token store and invalidates its peer.
The client’s second response writes over the first, and whichever response arrives later will overwrite the client’s stored refresh token.
If that one is the stale, already-revoked token, the next refresh fails and the user is logged out.
Why It Matters
- User Friction: Unexpected logouts for users who simply “clicked twice”
- Edge Bugs: Hard to reproduce, intermittent
- Security: Orphaned refresh tokens can linger or be replayed
A Simple Mitigation: Skew Your Cookie Expiry
Instead of setting your refresh‐token cookie’s Expires exactly to match the JWT’s exp claim, subtract a small delta—say, 30 seconds:
const REFRESH_TOKEN_LIFETIME = 60 * 60; // e.g. 1 hour in seconds
const COOKIE_SKEW_SECONDS = 30; // safety buffer
const refreshTokenExp = Date.now() + REFRESH_TOKEN_LIFETIME * 1000;
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
expires: new Date(refreshTokenExp - COOKIE_SKEW_SECONDS * 1000),
});
The browser will stop sending the cookie ~30 s before the server considers the token truly expired.
It prevents the race where one “losing” request arrives after the cookie has already been dropped, so it never attempts to rotate again with a stale token.
Picking the Right Delta
- 30 s–2 m buffer is usually plenty.
- Longer if your network is high-latency or clients might queue multiple requests offline.
other posts:
Let's connect!!: