← Back to the library
SecurityHow it works · Applied · 15 min

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

Refresh token rotation with reuse detectionEach refresh token works once. When a retired one comes back, the server cannot tell who is legitimate, so it kills the whole family.
  1. Legitimate client → Auth serverPOST /token grant=refresh_token RT1The attacker copied RT1 earlier, for example from XSS or a leaked log.
  2. Auth server → Legitimate clientAT2 + RT2; RT1 marked usedRT2 joins the same token family. Store only hashes of refresh tokens.
  3. Attacker → Auth serverPOST /token with stolen RT1
  4. Auth server → Auth serverRT1 already used: reuse detectedEither party could be the thief. RFC 9700: revoke the active refresh token.
  5. Auth server → Auth serverRevoke family: RT2 and all descendantsAlso alert or audit-log the user; optionally end linked sessions.
  6. Auth server → Attacker400 invalid_grant
  7. Legitimate client → Auth serverPOST /token with RT2 when AT2 expires
  8. 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.
Where the browser credential livesXSS can act as the user in every row while the page is open. The difference is whether it can take the credential away and keep using it.
XSS can steal it?CSRF exposureRevocationFits
Session ID, HttpOnly cookieNo; XSS can only ride the open sessionYes; SameSite plus Fetch Metadata or CSRF tokensImmediate: delete the server-side rowWeb apps on one site; the default choice
JWT in HttpOnly cookieNo, same as a session cookieYes, same defenses neededNot before exp without a denylist lookupEdge or multi-service checks without a store
JWT in JS memoryYes while the page runs; lost on reloadNone; sent in the Authorization headerNot before exp; keep lifetimes to minutesSPAs calling APIs directly, with refresh via a backend
JWT in localStorageYes, persistently, by any script on the originNoneNot before expAvoid for anything sensitive
BFF: tokens on the serverNo token reachable; XSS can still proxy callsYes; cookie-based, same defensesImmediate; upstream tokens revoked server-sideSPAs 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.
express-session + Redis documentation (opens in new tab)

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.
jose 6 documentation (opens in new tab)

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.
Token endpoint logic documentation (opens in new tab)

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.
SimpleWebAuthn 14 documentation (opens in new tab)

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.
Better Auth 1.x + Express 5 documentation (opens in new tab)

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.
Express 5 documentation (opens in new tab)

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.

SOURCES

Explainer reviewed