Authentication: sessions, tokens, passkeys and revocation
Should I use server sessions or JWTs, where do tokens live in a browser, and how do I actually log someone out?
Authentication proves who someone is once; the session is the credential that carries that proof across requests. Server sessions are revocable lookups, JWTs are self-contained claims that stay valid until they expire, and most real systems combine short-lived access tokens with rotated, revocable refresh tokens or a plain session cookie.
THE MENTAL MODEL
Every scheme answers two questions: what does the client present on each request, and where does the server learn that it is no longer valid. A session ID is a pointer: the server looks it up, so deleting the row logs the user out instantly, at the cost of a store read. A JWT is a signed copy of the answer: verification needs only a key, but the server cannot take it back, so its lifetime is your revocation latency. Refresh tokens bridge the two: a long-lived, server-tracked, single-use credential that mints short-lived access tokens, with reuse of an old one treated as theft. In browsers, the storage location matters more than the format: anything JavaScript can read, XSS can exfiltrate, which is why the strongest browser pattern keeps tokens on a backend and gives the page only an HttpOnly cookie. Passkeys and MFA harden the moment of login; none of them help if the resulting session can be stolen or fixed.
HOW IT FITS TOGETHER
- Legitimate client → Auth serverPOST /token grant=refresh_token RT1The attacker copied RT1 earlier, for example from XSS or a leaked log.
- Auth server → Legitimate clientAT2 + RT2; RT1 marked usedRT2 joins the same token family. Store only hashes of refresh tokens.
- Attacker → Auth serverPOST /token with stolen RT1
- Auth server → Auth serverRT1 already used: reuse detectedEither party could be the thief. RFC 9700: revoke the active refresh token.
- Auth server → Auth serverRevoke family: RT2 and all descendantsAlso alert or audit-log the user; optionally end linked sessions.
- Auth server → Attacker400 invalid_grant
- Legitimate client → Auth serverPOST /token with RT2 when AT2 expires
- Auth server → Legitimate client400 invalid_grant: sign in againHad the attacker refreshed first, its access token would stay valid until exp, so keep access lifetimes to minutes.
| XSS can steal it? | CSRF exposure | Revocation | Fits | |
|---|---|---|---|---|
| Session ID, HttpOnly cookie | No; XSS can only ride the open session | Yes; SameSite plus Fetch Metadata or CSRF tokens | Immediate: delete the server-side row | Web apps on one site; the default choice |
| JWT in HttpOnly cookie | No, same as a session cookie | Yes, same defenses needed | Not before exp without a denylist lookup | Edge or multi-service checks without a store |
| JWT in JS memory | Yes while the page runs; lost on reload | None; sent in the Authorization header | Not before exp; keep lifetimes to minutes | SPAs calling APIs directly, with refresh via a backend |
| JWT in localStorage | Yes, persistently, by any script on the origin | None | Not before exp | Avoid for anything sensitive |
| BFF: tokens on the server | No token reachable; XSS can still proxy calls | Yes; cookie-based, same defenses | Immediate; upstream tokens revoked server-side | SPAs handling business or personal data |
KEY TERMS
- Server-side sessions
- A random, opaque ID (at least 64 bits of entropy; libraries use 128+) in an HttpOnly cookie points at state in Redis or a database. Revocation, idle timeout, absolute timeout and "sign out everywhere" are row deletes. The cost is one store read per request, which is rarely the bottleneck.
- JWT access tokens
- A signed JSON claim set (iss, sub, aud, exp, jti) any service can verify with the issuer's public key. Pin the accepted algorithms, check iss, aud and exp, and treat the signature as integrity, not secrecy: the payload is readable. Because nothing is looked up, a JWT cannot be revoked before exp without reintroducing state (a jti denylist or a per-user token version).
- Refresh tokens and rotation
- Long-lived, opaque, stored server-side as hashes, and exchanged at the token endpoint for a new access token plus a new refresh token. RFC 9700 requires public clients to rotate refresh tokens or sender-constrain them (DPoP, mTLS). Reuse of a retired token revokes the family; give the family an absolute lifetime so rotation cannot extend a session forever.
- Token storage and the BFF pattern
- RFC 10017 (OAuth 2.0 for Browser-Based Applications) ranks three architectures: a Backend for Frontend that holds all tokens and gives the browser only an HttpOnly session cookie, a token-mediating backend, and a pure browser client. It strongly recommends the BFF for business, sensitive and personal-data applications.
- OAuth 2.0 and OpenID Connect roles
- OAuth delegates access: the client gets an access token from the authorization server to call a resource server on the user's behalf. OIDC adds an id_token so the client learns who logged in. Your app consuming an IdP is the client; do not use access tokens as proof of login or id_tokens as API credentials. The authorization code + PKCE explainer covers the flow.
- Passkeys and MFA
- A passkey is a WebAuthn credential: a key pair scoped to your RP ID, with the private key on the device or synced by the platform. The server stores only the public key and verifies a signature over a fresh challenge, so there is nothing to phish, reuse or leak from your database. TOTP and push add a second factor but remain phishable; SMS is the weakest. Passkeys with user verification count as phishing-resistant MFA on their own.
- Session fixation and hijacking
- Fixation: the attacker plants a known session ID before login and inherits the authenticated session. Fix by issuing a new ID on login and on every privilege change, and rejecting IDs you did not issue. Hijacking: a stolen ID is replayed. Limit it with HttpOnly, Secure, __Host- cookies, short idle and absolute timeouts, re-authentication for sensitive actions, and revocation on password change.
IN YOUR STACK
TypeScript · express-session + Redis Server
import session from "express-session";
import { RedisStore } from "connect-redis";
import { createClient } from "redis";
const redis = await createClient({ url: process.env.REDIS_URL }).connect();
app.set("trust proxy", 1); // TLS ends at the load balancer; needed for secure cookies
app.use(session({
name: "__Host-sid",
store: new RedisStore({ client: redis, prefix: "sess:" }),
secret: [process.env.SESSION_SECRET!, process.env.SESSION_SECRET_PREVIOUS!], // rotate keys
resave: false,
saveUninitialized: false, // no session row (or cookie) for anonymous visitors
rolling: true, // idle timeout: every response pushes maxAge forward
cookie: { secure: true, httpOnly: true, sameSite: "lax", path: "/", maxAge: 30 * 60_000 },
}));
app.post("/login", async (req, res, next) => {
const user = await verifyCredentials(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: "invalid_credentials" });
req.session.regenerate((err) => { // new ID on login defeats session fixation
if (err) return next(err);
req.session.userId = user.id;
req.session.authTime = Date.now(); // check an absolute lifetime in your auth middleware
req.session.save((err) => (err ? next(err) : res.status(204).end()));
});
});- The first secret signs new cookies; all listed secrets verify, so you can rotate without logging everyone out.
- rolling gives an idle timeout only. Enforce an absolute limit (for example 8 to 12 hours) from authTime, and regenerate again on role changes.
- The default MemoryStore leaks and does not scale past one process; never use it in production.
TypeScript · jose 6 Server
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
// Cached and refreshed on unknown kid; one instance per issuer, created at startup.
const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
export async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ["ES256"], // pinned: never trust the token's own alg header
issuer: "https://auth.example.com/",
audience: "https://api.example.com", // rejects tokens minted for other APIs
typ: "at+jwt", // RFC 9068 access tokens; rejects id_tokens
requiredClaims: ["sub", "jti"],
clockTolerance: "30s",
});
if (await revokedJtis.has(payload.jti!)) throw new Unauthorized("token_revoked");
return payload;
} catch (err) {
if (err instanceof Unauthorized) throw err;
if (err instanceof errors.JWTExpired) throw new Unauthorized("token_expired");
throw new Unauthorized("invalid_token"); // do not echo jose's message to clients
}
}- Pinning algorithms blocks alg confusion (an HS256 token signed with your RSA public key) and alg: none.
- typ only works if your issuer sets it; check one real token. Without it, audience is what stops an id_token being replayed at your API.
- The jti denylist only needs entries until each token's exp, so a Redis set with TTLs stays small.
TypeScript · Token endpoint logic Server
import { createHash, randomBytes } from "node:crypto";
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
export async function refresh(presented: string) {
const row = await db.refreshTokens.findByHash(sha256(presented));
if (!row || row.revokedAt || row.familyExpiresAt < new Date()) throw new InvalidGrant();
if (row.usedAt) { // a retired token came back: assume theft
await db.refreshTokens.revokeFamily(row.familyId);
await audit.warn("refresh_token_reuse", { userId: row.userId, familyId: row.familyId });
throw new InvalidGrant();
}
const next = randomBytes(32).toString("base64url");
await db.transaction(async (tx) => {
// UPDATE ... SET used_at = now() WHERE id = $1 AND used_at IS NULL
if (!(await tx.refreshTokens.markUsedIfUnused(row.id))) throw new InvalidGrant();
await tx.refreshTokens.insert({
familyId: row.familyId, userId: row.userId, hash: sha256(next),
familyExpiresAt: row.familyExpiresAt, // absolute lifetime does not slide
});
});
return { accessToken: await signAccessToken(row.userId, { expiresIn: "10m" }), refreshToken: next };
}- The conditional UPDATE makes concurrent refreshes safe: exactly one wins. Serialize refreshes in the client (a BFF does this naturally) or allow a few seconds of grace for the same client, otherwise two tabs trigger reuse detection.
- Store only hashes. A database leak should not hand out working refresh tokens.
- Sender-constrained tokens (DPoP, RFC 9449) are the alternative RFC 9700 accepts for public clients: a stolen token is useless without the client's private key.
TypeScript · SimpleWebAuthn 14 Client + server
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
const rp = { rpName: "Example", rpID: "example.com", origin: "https://app.example.com" };
app.post("/passkeys/options", requireUser, async (req, res) => {
const existing = await db.passkeys.listForUser(req.user.id);
const options = await generateRegistrationOptions({
rpName: rp.rpName, rpID: rp.rpID, userName: req.user.email,
excludeCredentials: existing.map((p) => ({ id: p.id, transports: p.transports })),
authenticatorSelection: { residentKey: "required", userVerification: "required" },
});
req.session.regChallenge = options.challenge; // single use, bound to this session
res.json(options);
});
app.post("/passkeys/verify", requireUser, async (req, res) => {
const expectedChallenge = req.session.regChallenge;
delete req.session.regChallenge;
if (!expectedChallenge) return res.status(400).json({ error: "no_pending_challenge" });
const { verified, registrationInfo } = await verifyRegistrationResponse({
response: req.body, expectedChallenge, expectedOrigin: rp.origin, expectedRPID: rp.rpID,
});
if (!verified) return res.status(400).json({ error: "registration_failed" });
const { credential, credentialBackedUp } = registrationInfo;
await db.passkeys.insert({ userId: req.user.id, ...credential, backedUp: credentialBackedUp });
res.status(201).end();
});- credential holds id, publicKey (bytes), counter and transports. Synced passkeys usually report counter 0, so do not reject on a non-increasing counter.
- In the browser, call startRegistration({ optionsJSON }) from @simplewebauthn/browser and POST the result to /passkeys/verify; the pre-v11 positional call only works through a deprecation warning. Catch InvalidStateError: the authenticator is already registered.
- The RP ID can be a registrable parent (example.com) so passkeys work on every subdomain; it cannot be changed later without re-registering.
TypeScript · Better Auth 1.x + Express 5 Server
import { betterAuth } from "better-auth";
import { fromNodeHeaders, toNodeHandler } from "better-auth/node";
import { haveIBeenPwned, twoFactor } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";
import { Pool } from "pg";
export const auth = betterAuth({
baseURL: "https://app.example.com",
database: new Pool({ connectionString: process.env.DATABASE_URL }),
emailAndPassword: { enabled: true, minPasswordLength: 12 },
session: {
expiresIn: 60 * 60 * 24 * 7, // database-backed, so revocable
updateAge: 60 * 60 * 24, // slide expiry at most once a day
freshAge: 60 * 10, // sensitive endpoints need a sign-in under 10 minutes old
},
plugins: [twoFactor({ issuer: "Example" }), passkey({ rpID: "example.com", rpName: "Example" }), haveIBeenPwned()],
});
app.all("/api/auth/*splat", toNodeHandler(auth)); // before express.json()
app.get("/api/me", async (req, res) => {
const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) });
if (!session) return res.status(401).end();
res.json(session.user);
});- Auth.js (NextAuth) has been maintained by the Better Auth team since September 2025, which recommends Better Auth for new projects; Auth.js still gets security fixes.
- Sessions live in your database by default; without a database Better Auth switches to stateless cookie sessions, which cannot be revoked early.
- Generate the schema with the Better Auth CLI, and keep the cookie cache short if you enable it: it delays revocation by its maxAge.
TypeScript · Express 5 Server
app.post("/logout", requireUser, async (req, res, next) => {
const { refreshFamilyId } = req.session;
if (refreshFamilyId) await db.refreshTokens.revokeFamily(refreshFamilyId);
req.session.destroy((err) => { // deletes the server-side row, not just the cookie
if (err) return next(err);
res.clearCookie("__Host-sid", { secure: true, httpOnly: true, sameSite: "lax", path: "/" });
res.set("Clear-Site-Data", '"cache", "storage"'); // drop cached pages and local data
res.status(204).end();
});
});
// Password change, "sign out everywhere", or a detected compromise.
export async function revokeEverything(userId: string, keepSessionId?: string) {
await sessions.destroyAllForUser(userId, { except: keepSessionId }); // needs a userId index
await db.refreshTokens.revokeAllForUser(userId);
await db.users.incrementTokenVersion(userId); // access tokens carry ver; verify rejects older
await notify.securityEmail(userId, "All other sessions were signed out");
}- Clearing the cookie alone is not logout: a copied session ID keeps working until the server forgets it.
- Redis session stores are keyed by session ID, so keep a per-user set of IDs to make "sign out everywhere" possible.
- Logout with an IdP is a separate step: call the provider's revocation endpoint (RFC 7009) for its refresh token and, if needed, OIDC RP-initiated logout.
WHEN TO REACH FOR WHAT
Server session in an HttpOnly cookie
Use when A web app and its API share a site, you need instant logout and per-device session lists, and one store read per request is acceptable.
Avoid when Many independent services on different domains must authenticate the same request without sharing a store.
Short-lived JWT + rotated refresh
Use when Several APIs or edge workers verify requests independently, or native and third-party clients call your APIs.
Avoid when You need revocation within seconds everywhere, or the JWT would just mirror a session you already store.
BFF holding OAuth tokens
Use when An SPA uses an external IdP or calls APIs with sensitive data; RFC 10017's recommended pattern.
Avoid when There is no server at all and the data is low risk; a pure browser client with in-memory tokens may be acceptable.
Tokens held by browser JavaScript
Use when Low-sensitivity SPA with no backend, using PKCE, in-memory access tokens and rotated refresh tokens.
Avoid when Any business or personal data, or a long-lived refresh token would sit in localStorage.
WHERE IT BITES
- Using a stateless JWT as the only session and then discovering logout, bans and password changes cannot take effect until exp.
- Keeping the same session ID across login. A fixed pre-login ID becomes an authenticated session for whoever planted it.
- Putting access or refresh tokens in localStorage. Any XSS, including one in a third-party script, exfiltrates them for use from anywhere.
- Verifying JWTs without pinning algorithms, issuer and audience, or accepting a token issued for another API because the signature checks out.
- Rotating refresh tokens without reuse detection or an absolute family lifetime, which turns a stolen token into a permanent session.
- Adding MFA or passkeys to login but not to account recovery, so attackers reset their way past the stronger factor.
CLOSE THE AI. EXPLAIN THIS.
A user reports their account was used after they logged out. Your app issues 15-minute JWT access tokens and 30-day refresh tokens stored in localStorage. List every place the attacker's credential could still be valid and what you would change at each layer.WHEN IT BREAKS IN PRODUCTION
SOURCES
- OWASP Session Management Cheat Sheet (opens in new tab)Checked
- RFC 9700: Best Current Practice for OAuth 2.0 Security (opens in new tab)Checked
- RFC 10017: OAuth 2.0 for Browser-Based Applications (opens in new tab)Checked
- SimpleWebAuthn: @simplewebauthn/server (opens in new tab)Checked
- Better Auth: Session management (opens in new tab)Checked
- Better Auth: Auth.js is now part of Better Auth (opens in new tab)Checked
Explainer reviewed