Skip to content
Backend & systems

JWT vs Session Authentication: Storage, Logout, and Real Trade-offs

“JWT or sessions?” mixes three decisions: what the credential contains, where the browser keeps it, and how the server takes it back. A session cookie carries a random handle to state kept on the server. A JWT carries signed claims that any service holding the key can check without a lookup. This guide compares both on storage, logout, refresh, XSS and CSRF, runs a standard-library HS256 example in which a tampered token fails verification, and ends with a decision table you can defend in an interview.

By Published

8 min readEditorial analysisUpdated
  • JWT
  • authentication
  • cookies
  • web security
  • backend
What to remember

A session keeps state on the server and makes logout instant; a JWT puts signed state in the client and makes revocation your problem. Decide by how fast access must be revoked and who verifies requests, and when a browser is involved, keep either credential in an HttpOnly, Secure cookie rather than in JavaScript-readable storage.

What each approach stores, and where

With server-side sessions, the server generates a random identifier, stores a session record (user ID, roles, CSRF token, expiry) in memory, Redis, or a database, and sends only the identifier to the browser in a cookie. The identifier means nothing by itself; the OWASP Session Management Cheat Sheet asks for at least 64 bits of entropy from a cryptographically secure generator so it cannot be guessed. Every request costs one keyed lookup, and the server can change or delete the record at any moment.

A JWT, defined in RFC 7519, is three base64url segments: header, claims, and signature. Base64url is an encoding, not encryption, so anyone holding the token can read sub, role, and exp. The signature gives integrity: a verifier with the key can confirm that the claims came from a key holder and were not altered. State moves from the server's store into the token itself, which is the source of both the convenience and the revocation problem.

Server session vs JWT access token
QuestionServer sessionJWT access token
Client holdsRandom opaque IDReadable signed claims
Server storesSession recordSigning or verification key
Per-request checkLook up the IDVerify alg, signature, exp, aud, iss
Logout takes effectImmediatelyAt exp, unless you add a denylist
Fits bestOne app, one backendMany services verifying callers

Sign, verify, and tamper with an HS256 token

This Node script uses only node:crypto. It signs a 15-minute token, then verifies it three ways. The verifier pins the algorithm instead of trusting the header, as RFC 8725 section 3.1 requires of libraries, compares signatures with timingSafeEqual, treats exp as the first second the token is no longer valid, which is how RFC 7519 defines it, and rejects a token that has no exp at all. The fixed 32-byte key keeps the output reproducible; a real key comes from a secret store and is never a password (RFC 8725 section 3.5).

Running node jwt.mjs on Node 26 prints four lines: {"sub":"u42","role":"user","iat":1794000000,"exp":1794000900}, then ok sub=u42 role=user, then rejected: signature, then rejected: expired. The first line is the point many candidates miss: the payload is readable without the key. The forged token changes role to admin and reuses the old signature, which no longer matches. In production, use a maintained library and also validate aud, iss, and the token type.

import { createHmac, timingSafeEqual } from "node:crypto";

const secret = Buffer.alloc(32, 7); // demo only: use 32 random bytes from a secret store
const b64 = (v) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v)).toString("base64url");
const mac = (input) => createHmac("sha256", secret).update(input).digest("base64url");

function sign(claims) {
  const head = b64({ alg: "HS256", typ: "JWT" }) + "." + b64(claims);
  return head + "." + mac(head);
}

function verify(token, nowSec) {
  const [h, p, s] = token.split(".");
  if (JSON.parse(Buffer.from(h, "base64url")).alg !== "HS256") return "rejected: alg";
  const want = Buffer.from(mac(h + "." + p)), got = Buffer.from(s ?? "");
  if (want.length !== got.length || !timingSafeEqual(want, got)) return "rejected: signature";
  const claims = JSON.parse(Buffer.from(p, "base64url"));
  if (typeof claims.exp !== "number" || nowSec >= claims.exp) return "rejected: expired";
  return `ok sub=${claims.sub} role=${claims.role}`;
}

const token = sign({ sub: "u42", role: "user", iat: 1794000000, exp: 1794000900 });
const [h, , s] = token.split(".");
const forged = [h, b64({ sub: "u42", role: "admin", iat: 1794000000, exp: 1794000900 }), s].join(".");

console.log(Buffer.from(token.split(".")[1], "base64url").toString());
console.log(verify(token, 1794000300));
console.log(verify(forged, 1794000300));
console.log(verify(token, 1794000900));

Logout, expiry, and refresh-token rotation

Logging out of a session is a delete: remove the record and the next request with that cookie fails. OWASP also asks for idle and absolute timeouts enforced on the server, and a new session ID after login to block session fixation. A JWT has no equivalent built in. RFC 7519 defines exp, nbf, and jti but no revocation mechanism, so a stolen access token stays valid until it expires. A denylist keyed on jti and iss closes the gap, but it is server state again, and the OWASP JWT cheat sheet notes that plain sessions may then be the simpler design.

The usual compromise is a short-lived access token, minutes rather than days, plus a long-lived refresh token that the authorization server tracks. RFC 9700 requires refresh tokens for public clients to be sender-constrained or rotated. With rotation, every refresh returns a new refresh token and invalidates the old one; if an invalidated token shows up again, the server cannot tell thief from user, so it revokes the active token and forces a new login. Logout then means revoking the refresh token and clearing cookies, while the current access token survives until exp. Name that window in an interview.

Storage: HttpOnly cookies, localStorage, XSS, and CSRF

Where the browser keeps the credential matters more than its format. Anything in localStorage or sessionStorage is readable by every script on the origin, so a single XSS bug can copy the token to an attacker who replays it from anywhere. The OWASP Session Management Cheat Sheet advises against keeping session IDs, JWTs, or refresh tokens in web storage and points to HttpOnly cookies or a backend-for-frontend instead. HttpOnly does not make XSS harmless, since injected script can still send requests as the user while the page is open, but script cannot read the token and copy it elsewhere.

Cookies bring the opposite risk. The browser attaches them automatically, so state-changing endpoints need CSRF protection: SameSite=Lax or Strict as defense in depth, plus a CSRF token or an Origin check for unsafe methods. A bearer token in an Authorization header is not sent automatically, which is why it avoids classic CSRF. That benefit disappears once you move the token into a cookie, and the XSS exposure appears once you move it into JavaScript.

  • Secure: the cookie is sent only over HTTPS.
  • HttpOnly: scripts cannot read it through document.cookie.
  • SameSite=Lax or Strict: not attached to most cross-site requests; never use None without Secure.
  • __Host- name prefix: requires Secure and Path=/ and forbids a Domain attribute, so the cookie goes only to the host that set it and a subdomain cannot plant one for you.

A decision table you can defend

Start from two questions: how quickly must a revoked user lose access, and who verifies the request? If one backend serves one web app, sessions answer both with less machinery. If several services must authorize calls without sharing a session store, short-lived signed tokens earn their complexity. Many systems use both: a session cookie between the browser and a backend-for-frontend, and JWT access tokens between that backend and internal APIs.

Common mistakes worth naming: calling a JWT encrypted, calling it stateless while also running a denylist, signing HS256 with a human-chosen password, accepting whatever alg the header announces, and claiming that sessions cannot scale. A shared store such as Redis lets any instance look up any session; the trade-off is one more dependency to run, not impossibility. Another mistake is issuing long-lived JWTs with no refresh path, which leaves rotating the signing key, and logging everyone out, as the only way to cut off one user.

Choosing by requirement
SituationPreferReason
Server-rendered app, one backendSession cookieInstant logout, least code
SPA and API on the same siteSession cookie or BFFKeeps tokens out of JavaScript
Many services verify callersShort-lived JWTLocal checks, no shared store
Third-party clients via OAuthTokens from an auth serverStandard grants and scopes
Immediate revocation requiredSession or introspectionNo exp window to wait out

Answer it in an interview, then practice

A strong answer runs in this order: what the client holds, what the server must store, how logout works, where the browser keeps the credential, and which attack each storage choice invites. Finish with your default and the condition that would change it, for example sessions for a single web app, and short-lived JWTs with rotated refresh tokens once several services need to verify callers independently.

Rebuild the HS256 script from memory and predict its four lines before running it, then change exp or the alg value in the header and predict again. If you can explain why the forged token fails and why the valid one fails at exactly exp, you understand both integrity and lifetime. The question sets below cover the surrounding authentication and HTTP topics, and RecallDeck's backend track keeps them in spaced review.

Quick answers

Frequently asked questions

Is a JWT more secure than a session cookie?

Neither format is secure by itself. A random session ID reveals nothing and dies on logout; a JWT exposes readable claims and lives until exp. Security comes from key handling, validating alg, aud and exp, storing the credential in an HttpOnly cookie, CSRF defenses, and how quickly you can revoke access.

Can I store a JWT in a cookie?

Yes, and for browsers it is often the better place. The server keeps JWT verification while the token stays out of JavaScript. You then inherit cookie rules: set Secure, HttpOnly and SameSite, and protect state-changing endpoints against CSRF, because the browser attaches the cookie automatically.

Should I sign tokens with HS256 or RS256?

HS256 uses one shared secret, so every service that can verify a token can also mint one. Asymmetric algorithms such as RS256 or ES256 let the issuer sign with a private key while services verify with the public key. RFC 8725 also requires each key to be used with exactly one algorithm.

Do server sessions work behind a load balancer?

Yes. Keep sessions in a shared store such as Redis or a database that every instance can read, or use sticky routing, which pins a user to one instance and, with in-memory sessions, loses their session if that instance restarts. The shared store is the usual choice because instances stay interchangeable.

Source notes

References and review policy

Information checked on November 7, 2026. Section links identify sources for factual claims and technical explanations. Interpretations, practice scenarios and preparation recommendations are RecallDeck’s editorial work.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going