Short-lived access tokens are universally considered best practice. Keep them under 15 minutes, the guidance says, and you limit the blast radius of a leaked credential. What the guidance rarely covers in detail is the other side of that bargain: if access tokens expire every 15 minutes, your users need a way to stay logged in without typing their password again. That's the refresh token's job — and the refresh token is where most authentication systems quietly fall apart.
This post covers the full lifecycle: how sliding window refresh works, why single-use enforcement matters, how to handle the race between concurrent requests, absolute TTL limits, and how revocation by jti gives you a proper kill switch.
The basic token pair model
The standard pattern is a two-token system. At login, your auth server issues:
- An access token — short-lived (5–15 min), stateless JWT, carried in the
Authorizationheader on every API request. - A refresh token — long-lived (days to weeks), stored securely, presented only to the auth server's
/token/refreshendpoint.
The access token is read by every microservice. The refresh token is read by one service. This asymmetry is the whole point: you get stateless verification at scale while keeping the sensitive long-lived credential locked down.
Sliding window refresh
A naive refresh token has a fixed expiry: issue it at login, it dies in 30 days regardless. The problem is active users — someone using your app daily should never be prompted to log in again, while someone who hasn't opened it in 30 days should be.
Sliding window solves this. Every time a refresh token is successfully used, you issue a new one whose expiry is reset from the current time:
// On successful /token/refresh
const OLD_TTL = 30 * 24 * 60 * 60; // 30 days sliding
const ABSOLUTE_TTL = 90 * 24 * 60 * 60; // 90 days hard cap
async function rotateRefreshToken(oldToken: string, db: DB): Promise<TokenPair> {
const record = await db.refreshTokens.findByToken(hash(oldToken));
if (!record || record.revokedAt) throw new AuthError('TOKEN_INVALID');
const now = Math.floor(Date.now() / 1000);
if (now > record.expiresAt) throw new AuthError('TOKEN_EXPIRED');
if (now > record.absoluteExpiry) throw new AuthError('SESSION_EXPIRED');
// Single-use: revoke immediately before issuing new one
await db.refreshTokens.revoke(record.jti, 'rotated');
const newJti = crypto.randomUUID();
const newToken = generateSecureToken();
await db.refreshTokens.create({
jti: newJti,
userId: record.userId,
tokenHash: hash(newToken),
family: record.family, // track token family for reuse detection
parentJti: record.jti,
issuedAt: now,
expiresAt: now + OLD_TTL,
absoluteExpiry: record.absoluteExpiry, // carry forward, never reset
});
return {
accessToken: issueAccessJWT(record.userId, newJti),
refreshToken: newToken,
};
}
The key detail: absoluteExpiry is set once at session creation and never extended. This ensures that no matter how active a user is, their session eventually forces a full re-authentication. Typical values: sliding window 14–30 days, absolute cap 90–180 days.
Refresh token rotation and single-use enforcement
Refresh token rotation means each use invalidates the old token and issues a fresh one. This creates a detection mechanism for theft: if an attacker has stolen a refresh token and uses it after the legitimate client already rotated it, the server will detect a reuse attempt on an already-revoked token.
The correct response to detecting reuse is to revoke the entire token family — all tokens descended from the same original login event. This is tracked by the family field above.
async function handleRefreshRequest(rawToken: string, db: DB) {
const record = await db.refreshTokens.findByToken(hash(rawToken));
if (!record) {
// Unknown token — either garbage or already garbage-collected
throw new AuthError('TOKEN_INVALID');
}
if (record.revokedAt) {
// Token was already used or explicitly revoked.
// This is a reuse attack signal — nuke the whole family.
const reason = record.revocationReason;
if (reason === 'rotated') {
// Someone is replaying an old token from this family
await db.refreshTokens.revokeFamily(record.family, 'reuse_detected');
throw new AuthError('REUSE_DETECTED'); // Force full re-login
}
throw new AuthError('TOKEN_REVOKED');
}
return rotateRefreshToken(rawToken, db);
}
Handling concurrent refresh requests
The single-use model has a practical problem: mobile apps and browser tabs often fire refresh requests concurrently. If two requests arrive simultaneously with the same valid refresh token, the first succeeds and invalidates the token — the second sees a "revoked" token and incorrectly triggers the reuse path.
The standard fix is a short reuse grace window, typically 30–60 seconds. If a "rotated" token is presented again within the grace period, return the same new token rather than revoking the family:
if (record.revokedAt && record.revocationReason === 'rotated') {
const secondsSinceRotation = now - record.revokedAt;
if (secondsSinceRotation < 60) {
// Within grace window — find the successor token and return it
const successor = await db.refreshTokens.findByParentJti(record.jti);
if (successor && !successor.revokedAt) {
return {
accessToken: issueAccessJWT(record.userId, successor.jti),
refreshToken: successor.plaintext, // you need to cache this briefly
};
}
}
// Outside grace window — genuine reuse attack
await db.refreshTokens.revokeFamily(record.family, 'reuse_detected');
throw new AuthError('REUSE_DETECTED');
}
Note that returning the successor token requires you to have the plaintext available during the grace window — which means caching it somewhere (Redis with a 60s TTL is appropriate). Never store plaintext in your primary DB.
Revocation by jti
Every JWT carries a jti (JWT ID) claim. For refresh tokens, this is your revocation handle. When a user logs out, changes their password, or you need to kill a session remotely, you revoke by jti:
-- Mark a single session as revoked
UPDATE refresh_tokens
SET revoked_at = NOW(), revocation_reason = 'logout'
WHERE jti = $1 AND user_id = $2;
-- Revoke all sessions for a user (password change, account compromise)
UPDATE refresh_tokens
SET revoked_at = NOW(), revocation_reason = 'force_logout'
WHERE user_id = $1 AND revoked_at IS NULL;
-- Index required for performance
CREATE INDEX idx_refresh_tokens_jti ON refresh_tokens(jti);
CREATE INDEX idx_refresh_tokens_user_revoked ON refresh_tokens(user_id, revoked_at)
WHERE revoked_at IS NULL;
For access tokens, revocation is harder because they're stateless. The common approach is to embed the session_id or a token_version in the access token and check it against a fast cache (Redis) on every request. This adds a network hop but gives you sub-minute revocation for access tokens too.
Storing refresh tokens: HttpOnly cookies
Where you store the refresh token is as important as how you rotate it. The options:
- localStorage — accessible to any JavaScript on the page. XSS = full compromise. Never use this for refresh tokens.
- sessionStorage — same XSS problem, and tokens die when the tab closes.
- HttpOnly cookie — not accessible to JavaScript at all. Only sent by the browser on HTTP requests. The correct choice.
// Express: set refresh token as HttpOnly cookie
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true, // not accessible to JS
secure: true, // HTTPS only
sameSite: 'strict', // no cross-site requests
path: '/auth/token', // scoped to the refresh endpoint only
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds
});
// Return access token in the response body (short-lived, OK to be in memory)
res.json({ accessToken, expiresIn: 900 });
Scoping the cookie to /auth/token with the path attribute is important. It means the browser will only send the refresh cookie when hitting that specific endpoint, not on every API call. This reduces the window for CSRF abuse even with sameSite: 'strict'.
Absolute TTL and forced re-authentication
No sliding window should be infinite. Set an absolute session maximum that cannot be extended no matter how active the user is. The right value depends on your threat model:
- Consumer apps: 90–180 days absolute TTL is generally acceptable
- Enterprise / high-value SaaS: 8–24 hours may be required by policy
- Financial / healthcare: potentially session-per-operation with step-up auth
When the absolute TTL expires, the user must re-authenticate from scratch. Make this experience graceful: show a clear "Your session has expired" message, preserve their in-progress state if possible, and return them to where they were after re-login.
Putting it together: the full schema
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
jti UUID NOT NULL UNIQUE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
family UUID NOT NULL, -- all tokens from same login share this
parent_jti UUID REFERENCES refresh_tokens(jti),
token_hash CHAR(64) NOT NULL, -- SHA-256 of the raw token, hex-encoded
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, -- sliding window expiry
absolute_expiry TIMESTAMPTZ NOT NULL, -- hard cap, never extended
revoked_at TIMESTAMPTZ,
revocation_reason VARCHAR(32), -- 'logout', 'rotated', 'reuse_detected', 'force_logout'
user_agent TEXT,
ip_address INET
);
Store the ip_address and user_agent for anomaly detection. A refresh token suddenly coming from a different continent is worth flagging, even if it's technically valid.