Rapid-Fire: Sessions, Tokens and JWT
The densest area of questioning — where the follow-ups separate people who have read about JWTs from people who have run them.
Sessions or JWTs — which do you pick?
Neither by default; it depends on whether you need instant revocation or stateless verification. Sessions give you a delete-the-row logout at the cost of a lookup per request. JWTs verify locally at the cost of staying valid until they expire. Most production systems are hybrids: a short stateless access token for cheap verification, and a stateful refresh token so revocation actually works.
How do you revoke a JWT?
Strictly, you cannot — that is what stateless means. In practice there are four options: short lifetimes so the window is small; a jti denylist in Redis with a TTL matching the remaining life; a tokenVersion counter on the user checked against a cached lookup; or opaque tokens with introspection, which is just sessions again. Name the trade-off you accepted and why.
What must you check when verifying a JWT?
Signature with a pinned algorithm, then iss, aud, exp, and nbf with a small clock tolerance. Skipping aud is the one that bites: a token minted for another service in the same estate is otherwise accepted.
What is algorithm confusion?
The server signs with RS256 and publishes the public key. If the verifier lets the token choose the algorithm, an attacker signs a forged token with HS256 using the public key as the HMAC secret, and it verifies. The fix is one option: an explicit algorithm allowlist.
Access token lifetime — what number and why?
Five to fifteen minutes. That number is your worst-case revocation delay, so it is not a style choice: it is the answer to "how long after we ban someone can they still act?"
What is refresh token rotation and why does it need reuse detection?
Rotation issues a new refresh token on every use, so a stolen one is useful only until the real client refreshes. Reuse detection is what turns that into a defence: a token presented twice means a copy exists, so you revoke the whole family. Rotation without detection just gives an attacker a quiet, self-renewing session.
Where do you store tokens in a browser?
Access token in memory; refresh token in an HttpOnly, Secure cookie scoped to the refresh path. On page load you call refresh once to restore the session. localStorage is one line for an XSS to exfiltrate.
Example
// The whiteboard answer to "how do you get revocation with stateless tokens?"
access token : JWT, 10 min, verified locally → no lookup on the hot path
refresh token : opaque, 30 days, row in Postgres → deleting the row is logout
// Ban a user:
await db.refreshTokens.revokeAllFor(userId); // no new access tokens
await db.users.increment(userId, 'tokenVersion');
// existing access tokens die within 10 minutes — or immediately if the
// middleware compares claims.ver against a 5-second-cached tokenVersion.
//
// State the number out loud. "Revocation is effective within 10 minutes,
// or 5 seconds if we accept one cached lookup per request."When to use it
- A candidate answers 'how do you revoke a JWT' by naming four mechanisms and the trade-off each accepts, rather than claiming it is impossible.
- An interviewer asks about algorithm confusion and the candidate can describe both the attack and the single option that fixes it.
- A design discussion lands on a hybrid because the candidate framed the choice as a revocation-delay budget rather than a technology preference.
More examples
The concurrency question almost nobody anticipates
If you have run token auth in production you have hit this. Saying so, and describing the symptom before the fix, is more convincing than the code alone.
// Q: "Your dashboard fires six API calls on load and the token has just
// expired. Walk me through what happens."
// The naive client:
// 6 x 401 → 6 x POST /auth/refresh → all six present RT1
// the first rotates RT1 → RT2
// the other five present an ALREADY-USED RT1
// reuse detection fires → the whole family is revoked
// your own client just logged the user out
// The fix is one shared promise:
let refreshing = null;
function sharedRefresh() {
return (refreshing ??= refresh().finally(() => { refreshing = null; }));
}
async function api(path, options = {}) {
let res = await send(await getToken(), path, options);
if (res.status === 401) res = await send(await sharedRefresh(), path, options);
return res;
}
// Why this question is asked: it only reproduces under concurrency WITH
// rotation enabled, so it survives development and manual QA and appears in
// production as "random logouts". Recognising it signals you have shipped this.Sessions vs tokens, as a decision not an opinion
Framing it as five questions shows you have made this decision before. Naming a concrete configuration at the end shows you have implemented it.
# Answer the QUESTIONS, and the technology falls out.
1. Who calls the API?
only our own browser app → sessions are viable
mobile / CLI / partners → tokens
2. Same site as the frontend?
abc.com + api.abc.com → cookies work comfortably
abc.com + dfg.com → cross-site: tokens, or move the API
3. What revocation delay is acceptable?
zero → sessions, or tokens + denylist
minutes → short-lived tokens
4. Can every instance reach a shared store?
yes → sessions are cheap
multi-region, no shared store→ stateless verification
5. Is there a frontend server?
yes → BFF: no token in the browser at all
# Then state the answer as a sentence with a number in it:
# "First-party browser app, same site, instant logout required → sessions in
# Redis with a 30-minute idle and 12-hour absolute timeout."
#
# "Mobile plus web on a different domain → 10-minute access tokens, rotating
# refresh tokens with reuse detection, revocation effective within 10 min."The live-coding request: verify a JWT
jwt.verify(token, key) with no options compiles, runs, and passes a happy-path test. The options are the entire exercise.
// A common exercise. What they are grading is which options you pass.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URL), {
cooldownDuration: 30_000, // an unknown kid cannot trigger unbounded fetches
cacheMaxAge: 600_000,
});
export async function authenticate(req, res, next) {
const header = req.get('authorization') ?? '';
if (!/^bearer /i.test(header)) return unauthorized(res, 'authentication_required');
try {
const { payload } = await jwtVerify(header.slice(7).trim(), JWKS, {
algorithms: ['RS256'], // ← pinned: kills alg confusion
issuer: 'https://auth.dfg.com', // ← who minted it
audience: 'https://dfg.com/api', // ← was it meant for US
clockTolerance: 30, // ← skew, but bounded
maxTokenAge: '15m', // ← reject an absurdly old iat
});
// Revocation — no library can do this part for you.
if (payload.jti && await denylist.has(payload.jti)) {
return unauthorized(res, 'token_revoked');
}
req.user = { id: payload.sub, scopes: String(payload.scope ?? '').split(' ') };
next();
} catch (err) {
return unauthorized(res,
err.code === 'ERR_JWT_EXPIRED' ? 'token_expired' : 'invalid_token');
}
}
function unauthorized(res, error) {
res.set('WWW-Authenticate', `Bearer realm="api", error="${error}"`);
return res.status(401).json({ error });
}
// Narrate while you type: "algorithms pinned so the token cannot choose,
// audience checked so another service's token is not accepted here, and the
// denylist because the library cannot know what we revoked."
Discussion