Sessions vs. Tokens: Cookies, JWTs, and Where Each One Bites

Once someone has authenticated, you have a smaller and more permanent problem: proving it's still them on the next request without asking them to log in every time. There are two honest answers: a server-side session or a self-contained token. Most of the auth bugs I see come from picking one without understanding what it costs.

Sessions: state the server remembers

A session is server-remembered state. You log in, the server stores a record and hands the browser an opaque ID in a cookie; every request presents the cookie, the server looks it up. The nice property is control: logging someone out, killing a compromised session, or forcing re-auth is a single delete on the server, effective immediately. The cost is that the server has to keep and check that state. That's a non-issue for one app, and a real design question once you have a fleet of services that all need to agree on who's logged in.

flowchart LR subgraph Session["Server session"] direction TB S1["Cookie: opaque ID"] --> S2["Server looks up state"] S2 --> S3["Revoke = delete record<br/>(instant)"] end subgraph Token["JWT / token"] direction TB T1["Token carries claims + signature"] --> T2["Verify with key,<br/>no lookup"] T2 --> T3["Revoke = hard<br/>(denylist / short TTL)"] end Session ~~~ Token
Sessions: state the server remembers

Tokens: proof that travels with the request

A token, usually a JWT, flips it around. Instead of a lookup key, the token itself carries the claims (who you are, when it expires) and a signature the server can verify without storing anything. That statelessness is genuinely useful: any service holding the public key can validate the token, no shared session store required, which is why it took over APIs and microservices. But the same property is the trap. Because nothing is looked up, you can't easily take a valid token back. It's good until it expires, and "just revoke it" means rebuilding the very server-side state you adopted JWTs to avoid: a denylist, or short lifetimes plus refresh tokens, which is most of a session with extra steps.

Where you store it, and which bug you accept

Then there's where you put it, which is its own footgun. A cookie can be HttpOnly, so JavaScript can't read it, which takes token theft via XSS off the table. But cookies ride along automatically on every request, which is exactly what CSRF abuses, so now you need SameSite and anti-CSRF tokens. Put a JWT in localStorage to dodge CSRF and you've made it readable by any script that runs on your page, so one XSS and the token walks out the door. There's no placement that dodges both; you're choosing which class of bug you'd rather defend against.

flowchart TD C["HttpOnly cookie"] -->|"JS can't read it"| C1["XSS: safe"] C -->|"sent automatically"| C2["CSRF: exposed<br/>(needs SameSite + token)"] L["Token in localStorage"] -->|"readable by any script"| L1["XSS: exposed"] L -->|"not sent automatically"| L2["CSRF: safe"]
Where you store it, and which bug you accept

What I actually use

My rule of thumb: for a normal web app with a browser and a login, server sessions in HttpOnly cookies are the boring correct answer, and it's what I use here. Instant revocation matters more than saving a lookup. Reach for tokens when you actually have the problem they solve: service-to-service calls, or enough backends that they can't share a session store. Both are fine tools. The mistakes come from choosing the stateless one because it sounds modern, then reinventing state the first time you need to log somebody out.