Concept
The beginner framing: after a user proves who they are once (logging in), the app needs some way to recognize them on every subsequent request without asking for their password again, sessions and tokens are the two dominant patterns for doing that, and they make genuinely different tradeoffs, not just cosmetic ones.
Session cookies, server-side state, browser-managed transport
Step through the full lifecycle, login, automatic reattachment, and expiry:
POST /login { email, password }// server verifies credentials against the database
The client sends credentials once, over the login request, this is the only point at which the raw password is ever transmitted.
A session cookie holds an opaque identifier; the actual session DATA (who this user is, their permissions) lives server-side, looked up by that identifier on each request. This makes sessions inherently, trivially revocable, deleting the server-side session record immediately invalidates it, regardless of whether the client still holds the cookie. The browser handles attachment automatically (no client-side code needed to remember or attach anything), which is both the convenience AND the CSRF exposure covered in depth in the CSRF topic, automatic attachment cuts both ways.
Bearer tokens, self-contained, stateless, NOT automatically attached
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjQyfQ...A bearer token (typically a JWT) is self-contained, the server can verify it (checking the signature) without a database lookup, since the token itself carries the claims. This statelessness is the core tradeoff in the OTHER direction from sessions: it scales beautifully (no shared session store needed across server instances) but is NOT trivially revocable, a stateless token remains valid until it expires, full stop, since there's no server-side record to delete. Critically, unlike a cookie, a bearer token is NEVER attached automatically, client code must explicitly read it (from memory or storage) and set the Authorization header on every request, which also means it's immune to the classic CSRF pattern (no automatic browser attachment means no forged cross-site request can carry it along) but exposes it to a different risk: anywhere the token is stored client-side accessible to JavaScript is readable by an XSS payload.
Refresh token rotation, the mechanism that makes tokens genuinely revocable too
Step through expiry, silent refresh, and theft detection via rotation-on-use:
// Client holds: access_token (15 min), refresh_token (7 days)GET /api/dataAuthorization: Bearer <access_token>
While the access token is valid, requests are simple and fast, signature verification requires no database lookup for a stateless JWT-style token.
The standard pattern pairs a short-lived access token (minutes, limits the damage window of a leak) with a longer-lived refresh token that IS checked against server-side state (making it revocable, unlike the stateless access token). Rotation-on-use, issuing a brand-new refresh token on every use and invalidating the old one, adds genuine theft detection: if a stolen refresh token is ever used by an attacker, the legitimate client's next refresh attempt will present the now-invalidated original, a mismatch the server can recognize as a signal to revoke the entire token family, not just silently accept whichever party asks first.
Cookie security attributes, confirmed against this app's own defaults
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=LaxThree attributes doing three distinct jobs: HttpOnly (JS cannot read via document.cookie, an XSS mitigation, covered in XSS), Secure (only sent over HTTPS), SameSite=Lax (restricts cross-site attachment, a CSRF mitigation, covered in CSRF). This app's own next-auth v5 setup ships all three by default, confirmed in that topic's verification.
Try It
Predict the outcome before checking the solution.
// A mobile app (native iOS/Android, no browser, no cookie jar) needs to
// authenticate against the same backend API a web app uses.Given that mobile apps have no automatic cookie-jar behavior tied to a browser session the way a web page does, would a cookie-based session work well for this client, or would bearer tokens be the better fit?
Solution
Bearer tokens are the better fit here, this is exactly the scenario where the "sessions vs tokens" framing as a strict binary breaks down usefully. Cookies' automatic-attachment convenience is fundamentally a BROWSER behavior, native mobile apps don't have an equivalent browser cookie jar with the same automatic same-origin attachment semantics, so a cookie-based session doesn't get the "no client code needed" benefit that makes it attractive for a web app in the first place; the mobile app would need to manually manage the cookie anyway, at which point it's doing the SAME manual work a bearer-token approach requires, without gaining any of the automatic-attachment convenience. Bearer tokens, which are ALWAYS explicitly attached by client code regardless of platform, fit naturally here with no browser-specific mechanism to work around. This is exactly why a well-designed system commonly uses BOTH patterns: session cookies for the first-party web app (getting the automatic-attachment convenience where a real browser IS involved), and bearer tokens for mobile apps, server-to-server calls, and third-party API consumers, picking the mechanism that fits each specific client, not forcing one pattern everywhere.
Implement It Yourself
Build a minimal refresh-token rotation mechanism, the actual theft-detection logic from the AuthFlowVisualizer's token-refresh-rotation preset:
const crypto = require("crypto");
const refreshTokenStore = new Map(); // tokenId -> { userId, familyId, valid }
function issueTokenFamily(userId) {
const familyId = crypto.randomUUID();
const tokenId = crypto.randomUUID();
refreshTokenStore.set(tokenId, { userId, familyId, valid: true });
return { refreshToken: tokenId, familyId };
}
function refreshAccessToken(oldTokenId) {
const record = refreshTokenStore.get(oldTokenId);
if (!record)
The mechanism: every refresh token belongs to a familyId tracing back to the original login. Reuse of a dead (already-rotated) token is the theft signal, it means someone has a copy of a token that was already legitimately exchanged, which should only ever happen if it leaked. Revoking the whole family (not just the reused token) is deliberate: it forces BOTH the attacker and the legitimate client to re-authenticate, which is the safe failure mode when theft is suspected but which specific party is legitimate is unknown.
Under the Hood
This topic's AuthFlowVisualizer is shared with OAuth 2.0 & OpenID Connect, the session-cookie-flow and token-refresh-rotation presets used here, and the oauth-code-flow preset used there, are three faces of the same underlying authentication-lifecycle problem. And every mechanism here builds directly on the cookie-attribute discipline established in CSRF (SameSite) and XSS (HttpOnly), sessions vs. tokens is fundamentally a question of WHERE trust state lives, but the attributes protecting whichever mechanism you choose are the same ones covered mechanically in those topics.
Common Mistakes
1. Storing a bearer token in localStorage "because it's easier than cookies"
localStorage.setItem("token", accessToken); // ❌ fully readable by ANY script on the page, including an XSS payloadUnlike an HttpOnly cookie, anything in localStorage/sessionStorage is directly readable by JavaScript, including an attacker's injected script from an XSS bug. If a token must be held client-side in JS-accessible storage, that's a real, direct XSS-exposure tradeoff worth being deliberate about, not a default choice made purely for convenience.
2. Treating "stateless" as strictly better than "revocable"
"JWTs are stateless, so they're just better than sessions." // ❌ oversimplifiedStatelessness is a genuine scalability advantage, but it's a direct tradeoff against revocability, a leaked stateless access token remains valid until expiry, period, with no way to kill it early. Whether that tradeoff is acceptable depends entirely on the access token's lifetime and the specific threat model, not a universal ranking.
3. Rotating refresh tokens without theft-detection logic
function refresh(oldToken) {
return issueNewToken(); // ❌ rotates, but never checks if oldToken was ALREADY dead
}Rotation alone (without checking whether the presented token was already invalidated) gets you SOME benefit (limiting a stolen token's usable window to one refresh cycle) but misses the actual theft-detection signal shown in Implement It Yourself, reuse of a dead token is exactly the event that should trigger family-wide revocation.
Best Practices
- Use session cookies for first-party web app authentication, getting automatic browser attachment and trivial server-side revocability.
- Use bearer tokens for mobile apps, server-to-server calls, and third-party API consumers, anywhere there's no browser cookie jar providing automatic attachment in the first place.
- Keep access tokens short-lived (minutes) specifically to bound the damage window of a leak, paired with a genuinely revocable refresh token.
- Implement refresh-token rotation WITH reuse detection, not rotation alone, the theft-detection signal requires actually checking for dead-token reuse, not just issuing new tokens.
- Never store a bearer token somewhere JS-readable unless the tradeoff against XSS exposure has been deliberately considered, prefer
HttpOnlycookie-based delivery when the client is a browser and the choice is available.
Performance Tips
- Stateless token verification (checking a signature) requires no database round-trip, a real, meaningful latency win at high request volume compared to a session lookup on every single request.
- Session-store lookups can be kept fast with an in-memory or Redis-backed session store rather than a full relational database query per request, the "sessions are always slower" intuition is really about WHERE the session data lives, not an inherent property of the session pattern itself.
