Web Authentication: Three Decisions You're Conflating
Untangling state location, client storage, and transmission in web auth
Update (January 2026): This article was originally framed as “cookie-based vs. token-based” authentication. That framing conflates separate concerns. This revised version untangles them.
Update (October 2026): Added a quick comparison, a direct answer to “JWT vs. session”, and whether to store a JWT in a cookie or localStorage.
Authentication confirms that users are who they say they are. (Authorization, by contrast, determines what a user can do.) When implementing auth for a web app, you face three independent decisions:
- Where is session state stored? Server (stateful) vs. token (stateless)
- Where is the credential stored client-side? Cookies vs. localStorage
- How is the credential transmitted? Cookie header vs. Authorization header
The common “cookie-based vs. token-based” framing conflates all three. This article examines each decision separately, then covers the security tradeoffs and common patterns that emerge from combining them.
Cookie vs. Token: Quick Comparison
What people usually mean by “cookie-based” is a session ID in an HttpOnly cookie. “Token-based” usually means a JWT sent in the Authorization header:
| “Cookie-based” (session ID in cookie) | “Token-based” (JWT in header) | |
|---|---|---|
| Session state | Server (session store) | In the token |
| Sent by | Browser, automatically | Your code, explicitly |
| Readable by JavaScript | No (HttpOnly) | Yes |
| Main risk | CSRF (mitigated by SameSite and CSRF tokens) | XSS token theft |
| Revocation | Delete the session | Hard until the token expires |
| Best for | Browser apps with their own backend | APIs, mobile apps, third-party clients |
The two columns are just two of many combinations. You can put a JWT in an HttpOnly cookie, too. The sections below explain each decision.
1. Where Is Session State Stored? (Session vs. JWT)
This is the fundamental architectural choice.
Stateful (Session-based)
The server maintains session state:
- User logs in → server creates a session (in memory, database, or Redis)
- Server sends a session ID to the client
- Client sends the session ID with every request
- Server looks up the session ID to verify the user
The session ID is just a reference—it contains no user information. Every request requires a server-side lookup.
sequenceDiagram participant Client participant Server participant SessionStore as Session Store Client->>Server: Login (username, password) Server->>SessionStore: Create session SessionStore-->>Server: Session ID Server-->>Client: Set-Cookie with session_id Client->>Server: Request with session cookie Server->>SessionStore: Lookup session SessionStore-->>Server: User data Server-->>Client: Response
Sidenote: “Session cookie” refers to a cookie without Max-Age/Expires (deleted when browser closes). This is unrelated to session-based auth—any cookie can store a session ID.
Stateless (Token-based)
The token itself contains the session:
- User logs in → server creates a signed token with user data (claims)
- Server sends the token to the client
- Client sends the token with every request
- Server verifies the signature and reads claims directly—no lookup needed
The most common format is JSON Web Tokens (JWTs). Learn more at jwt.io. So “JWT vs. session” is usually shorthand for stateless vs. stateful auth: a JWT is verified with a key, a session ID is looked up in a store. Strictly speaking, JWT is just a token format, and many systems combine JWTs with server-side state, e.g. for revocation. Where the credential is stored and how it is sent are separate decisions (see below).
sequenceDiagram participant Client participant Server Client->>Server: Login (username, password) Server->>Server: Create and sign JWT Server-->>Client: JWT (contains user claims) Client->>Server: Request with Authorization header Server->>Server: Verify signature and read claims Server-->>Client: Response
Important: JWTs are signed, not encrypted. The payload is base64-encoded and visible to anyone. Never store sensitive data in a JWT unless you use encrypted tokens (JWE).
2. Where Is the Credential Stored Client-Side?
This decision is independent of stateful vs. stateless. Both session IDs and JWTs can be stored in either location:
| Cookies | localStorage/sessionStorage | |
|---|---|---|
| Sent automatically | Yes (to matching domain) | No (must add manually) |
| JS accessible | Only without HttpOnly | Always |
| Size limit | ~4KB | ~5-10MB |
Should You Store a JWT in a Cookie or localStorage?
For browser apps, prefer an HttpOnly, Secure cookie with SameSite=Lax (or Strict). JavaScript can’t read it, so
an XSS bug can’t steal the token. SameSite blocks many cross-site requests, but not all of them (see
CSRF below), so still protect state-changing requests with a CSRF token or by
checking the Origin header. A JWT in localStorage is readable by any script on your page, including injected ones.
If your code must attach the token itself, e.g. for an Authorization header to an API on another domain, keep the
access token in memory (a JavaScript variable) rather than in localStorage, and get a new one via a refresh token in an
HttpOnly cookie after a reload. Injected scripts can still use an in-memory token while the page is open, but they
can’t simply read it from storage later. Persisting tokens in localStorage is a conscious tradeoff: keep them
short-lived and invest in XSS protection.
3. How Is the Credential Transmitted? (Cookie vs. Authorization Header)
Also independent:
- Cookie header: Browser automatically includes cookies for the matching domain
- Authorization header: Client explicitly adds
Authorization: Bearer <token>
“Bearer token vs. cookie” is a comparison of transport, not of token type. A JWT can travel in a cookie, and a session ID can travel in an Authorization header. Both are bearer credentials: whoever holds one can use it. The practical differences:
- Who attaches it: The browser attaches cookies, including on requests triggered by other sites (subject to
SameSite). AnAuthorizationheader is only sent when your code adds it. - What JavaScript needs: To set the header, your code must be able to read the token, so it can’t live in an
HttpOnlycookie. - Clients and origins: The header works the same for browsers, mobile apps, CLIs, and server-to-server calls. Cookies are browser-centric; cross-origin
fetchcalls only send them withcredentials: 'include', and the server must answer withAccess-Control-Allow-Credentials: truefor your code to read the response.
Rule of thumb: use cookies for a browser app talking to its own backend, and the Authorization header for APIs consumed by non-browser clients or third parties.
Security Tradeoffs
Each decision has security implications. Here’s how they interact:
XSS (Cross-Site Scripting)
Affected by: client-side storage (decision #2)
- HttpOnly cookies: Token theft prevented—JavaScript cannot access them. However, XSS can still make authenticated requests while the user is on the page (session riding).
- localStorage: Fully vulnerable. Malicious scripts can steal and exfiltrate tokens.
CSRF (Cross-Site Request Forgery)
Affected by: transmission method (decision #3)
- Cookie header: Vulnerable by default. Browsers auto-send cookies, so attackers can trigger authenticated requests from other sites.
- Authorization header: Protected. Attackers can’t read your localStorage (same-origin policy), so they can’t include the token.
Mitigation
- CSRF: Set
SameSite=Laxexplicitly (only some browsers, like Chrome, default to it). It blocks cookies on cross-site POSTs but still sends them on top-levelGETnavigations, and “same-site” includes your other subdomains. So keepGETrequests free of side effects and protect state-changing requests with CSRF tokens orOrigin/Fetch Metadata checks (OWASP CSRF cheat sheet).SameSite=Strictis stronger but breaks legitimate flows like following a link from another site while logged in. - XSS: No storage mechanism fully protects you. Sanitize input, use CSP headers, avoid
dangerouslySetInnerHTML.
Token Revocation
Affected by: state location (decision #1)
- Stateful: Easy—delete the session server-side.
- Stateless: Hard—token valid until expiration. Workarounds: short expiry, blacklists (reintroduces state), refresh token rotation.
Performance
Affected by: state location (decision #1)
- Stateful: Database/cache lookup per request. Adds latency, but session data can be large.
- Stateless: Signature verification per request (fast, CPU-bound). Token sent with every request—JWTs can grow large.
Common Patterns
Real-world implementations combine these decisions strategically:
Stateful + HttpOnly cookies (decisions: server state, cookies, cookie header) The classic approach. Simple, easy revocation, works for most apps. Requires shared session store for multiple servers (Redis, database).
JWTs in HttpOnly cookies (decisions: token state, cookies, cookie header)
Stateless auth with token-theft protection. Add SameSite=Lax and CSRF tokens for CSRF protection. Scales horizontally without shared state, but revocation is harder.
Access + refresh tokens (decisions: hybrid state, cookies, cookie header) Short-lived access tokens (5-15 min) verified statelessly. Long-lived refresh tokens in HttpOnly cookies, validated server-side. Stateless performance with stateful revocation.
localStorage + Authorization header (decisions: token state, localStorage, auth header) Common in SPAs. No CSRF risk, but fully vulnerable to XSS token theft. Only use if you have strong XSS protections and accept the risk.
Summary
Choose based on your constraints:
- Need easy revocation? → Stateful (or hybrid with refresh tokens)
- Need horizontal scaling without shared state? → Stateless JWTs
- Default for most web apps: HttpOnly cookies +
SameSite=Lax+ CSRF tokens (orOriginchecks) with either approach. Protects against token theft and CSRF with minimal complexity.