Lesson 02 of 04
Sessions or JWTs
The real question is not which is more modern. It is whether you can revoke a credential before it expires.
OutcomesAfter this you will be able to
- State the actual trade between stateful and stateless auth
- Explain why "JWTs scale better" is usually irrelevant to your app
- Design a refresh-token flow with rotation and reuse detection
01The trade is revocation, not scale
A session is a random opaque id in a cookie, with the real state in your database. Every request costs one lookup. In exchange, deleting the row logs the user out instantly — which is what you want when a laptop gets stolen, a password changes, or an admin gets fired.
A JWT carries its claims in the token, signed. No lookup needed, so no database round trip. In exchange it is valid until it expires, and you cannot take it back. To revoke it you need a blocklist — which is a database lookup on every request, which is the thing you gave up sessions to avoid.
02A defensible default
For a normal web application with one backend: use sessions. Simpler, revocable, and the cookie is already going out with every request.
Reach for JWTs when a token has to be verified by a service that cannot reach your session store — cross-service auth, a third party consuming your API, a genuinely stateless edge function. Then keep the access token short-lived, five to fifteen minutes, and pair it with a refresh token you do store and can revoke.
03Refresh rotation with reuse detection
If you use refresh tokens, rotate them: each refresh returns a new token and invalidates the old one. The payoff is that a stolen token becomes detectable. If an old token is ever presented again, either the attacker or the real user is replaying — you cannot tell which, so kill the entire token family and force a fresh login.
export async function refresh(presented: string) {
const row = await db.query.refreshTokens.findFirst({
where: eq(refreshTokens.token, hashToken(presented)),
});
if (!row) return { error: 'invalid' };
// already rotated away = replay. one of the two holders is an attacker.
if (row.rotatedAt) {
await db.delete(refreshTokens).where(eq(refreshTokens.familyId, row.familyId));
return { error: 'reuse detected, session revoked' };
}
await db.update(refreshTokens)
.set({ rotatedAt: new Date() })
.where(eq(refreshTokens.id, row.id));
return issuePair(row.userId, row.familyId);
}- Store a hash of the refresh token, not the token. It is a credential — treat it like a password.
- Regenerate the session id on privilege change (login, role change) to prevent session fixation.
- Give sessions both an idle timeout and an absolute maximum lifetime.
Exercise
Revoke yourself
Log in on two browsers. From one, trigger a "log out all devices". If the second browser can still make an authenticated request, you have a revocation gap — which is the entire subject of this lesson, and it is extremely common in JWT setups that skipped the blocklist.