Part of the MediaBridge series.
The Problem with Silent Token Refresh
A 5-minute access token is short enough to be practical for security but creates a UX problem. When a page loads and fires several API calls simultaneously, the token might expire in the middle of that burst. Suddenly all five requests get a 401 TOKEN_EXPIRED at roughly the same time.
The naive fix is to retry each 401 by refreshing the token. But if five requests all hit 401 and all try to refresh, you get five simultaneous calls to /auth/refresh. Refresh tokens are single-use. The first call succeeds and rotates the token. The next four present the now-used refresh token and get REFRESH_TOKEN_USED. Those requests fail and the user gets logged out, not because their session was invalid but because the client raced itself.
The queue pattern solves this.
Token Architecture
Access tokens are JWTs signed with HS256, valid for 5 minutes. They carry userId, email, and isAdmin. The secret is JWT_SECRET from the environment. Tokens are verified on every authenticated request.
Refresh tokens are 128-character hex strings generated from 64 random bytes:
export function generateRefreshToken(): string {
return crypto.randomBytes(64).toString('hex');
}
They are stored as SHA-256 hashes in refresh_tokens, never in plaintext. The raw token travels only in an httpOnly cookie (mb_refresh) with SameSite=Strict and Secure=true in production. The database never sees the token value - only its hash.
Access tokens are stored in localStorage on the frontend. They are short-lived by design - if one leaks, it expires in 5 minutes. The refresh token cannot be read by JavaScript at all.
Refresh Token Rotation
Every refresh is a rotation. The endpoint:
- Reads the refresh token from the cookie
- Hashes it and checks
used_refresh_tokens- if found, rejects immediately - Looks up the hash in
refresh_tokensand confirms it has not expired - Inserts the hash into
used_refresh_tokens(marks it consumed) - Deletes the row from
refresh_tokens(removes the old token) - Issues a new access token and a new refresh token
await sql`INSERT INTO used_refresh_tokens (token_hash) VALUES (${tokenHash})`;
await sql`DELETE FROM refresh_tokens WHERE id = ${stored.id}`;
const token = await issueTokens(stored.user_id, reply);
return { token };
issueTokens creates a fresh access token and a new refresh token, inserts the new hash into refresh_tokens, and sets the new cookie. Each successful refresh produces a completely fresh pair.
The used_refresh_tokens table exists specifically for reuse detection. If the same refresh token arrives twice - whether from a bug or a theft attempt - the second call is rejected and the frontend redirects to login.
The Queue Pattern
The axios client module holds two module-level variables:
let isRefreshing = false;
let queue: Array<{ resolve: (token: string) => void; reject: (err: unknown) => void }> = [];
The response interceptor handles TOKEN_EXPIRED 401s:
if (code === 'TOKEN_EXPIRED' && !original._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
queue.push({ resolve, reject });
}).then((token) => {
original.headers.Authorization = `Bearer ${token}`;
return client(original);
});
}
original._retry = true;
isRefreshing = true;
try {
const res = await axios.post(`${BASE_URL}/auth/refresh`, {}, { withCredentials: true });
const newToken = res.data.token;
localStorage.setItem('mb_token', newToken);
processQueue(null, newToken);
original.headers.Authorization = `Bearer ${newToken}`;
return client(original);
} catch (err) {
processQueue(err, null);
localStorage.removeItem('mb_token');
window.location.href = '/login';
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
When the first request hits a TOKEN_EXPIRED, it sets isRefreshing = true and calls /auth/refresh. Every subsequent request that hits TOKEN_EXPIRED while isRefreshing is true skips the refresh call and pushes itself onto the queue. When the refresh completes, processQueue resolves all queued promises with the new token. Each queued request retries itself with the updated Authorization header.
function processQueue(error: unknown, token: string | null) {
queue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token!);
});
queue = [];
}
If the refresh fails, processQueue is called with the error. All queued requests reject, and the client redirects to login.
Force Logout Propagation
Two additional error codes bypass the queue entirely and terminate the session immediately:
if (code === 'FORCE_LOGGED_OUT' || code === 'REFRESH_TOKEN_USED') {
localStorage.removeItem('mb_token');
window.location.href = '/login';
return Promise.reject(error);
}
FORCE_LOGGED_OUT comes from the backend when a path traversal attempt is detected. REFRESH_TOKEN_USED comes when a refresh token is presented that has already been consumed. Both are hard stops - no queuing, no retry, straight to the login page.
Rate-Limit Circuit Breaker
The same client module also handles the rate limit case with a separate queue. When a 429 response arrives, the client enters rate-limited mode. All subsequent requests queue behind a gate. The gate drains one request at a time with exponential backoff between each, resetting to normal after 5 seconds with no further 429s:
function rlBump() {
rlActive = true;
rlBackoffMs = Math.min(rlBackoffMs * 2, 30_000);
if (rlTimer) clearTimeout(rlTimer);
rlTimer = setTimeout(() => {
rlActive = false;
rlBackoffMs = 500;
rlTimer = null;
rlFlush();
}, 5_000);
}
This matters for the thumbnail generation feature. When a folder with many images loads, the frontend queues up to 5 concurrent thumbnail generation requests. If those hit the rate limit, the circuit breaker prevents a thundering herd of retries from making the situation worse.
/auth/me as the Session Gate
On every page load, the frontend calls GET /auth/me. This endpoint checks the JWT, queries the force_logout table, and returns the current user including menuItems. It is the single point where session state is resolved on startup.
If the token has expired, /auth/me returns TOKEN_EXPIRED, triggering the refresh flow. If a force logout is in effect with a timestamp newer than the token’s iat, it returns FORCE_LOGGED_OUT. The frontend renders nothing until this call resolves cleanly.