Sessions vs JWT: The Auth Token Debate
Sessions store state on the server. JWTs store state in the token itself. One gives you instant revocation. The other gives you stateless scaling. Neither is universally better -- the right choice depends on what you're building.
The Login Problem
Every web application faces the same challenge: HTTP is stateless. Each request arrives with no memory of the last. But your users need to log in once and stay logged in. Somehow, every subsequent request needs to carry proof that this user already authenticated.
There are two dominant approaches to this problem, and the debate between them has been raging for over a decade.
Sessions say: "The server remembers who you are." You get a random ID, the server stores your identity, and each request sends the ID back for a lookup.
JWTs say: "The token itself proves who you are." All the information is encoded and signed into the token. The server doesn't need to remember anything.
Both work. Both have real trade-offs. And the internet is full of strong opinions about which one is "right." Let's actually break down what happens under the hood.
How Sessions Work
When a user logs in, the server creates a session -- an entry in a server-side store (memory, Redis, a database) that maps a random session ID to the user's data. The ID is sent to the client as a cookie, and the browser automatically includes it on every subsequent request.
On every subsequent request:
Browser → GET /api/profile
Cookie: sid=abc123xyz...
Server → Look up "abc123xyz" in Redis
→ Found: { userId: 42, role: "admin" }
→ Return user's profile data
The session store is the server-side database that holds session data. It can be in-memory (fast but lost on restart), Redis (fast and persistent), or a traditional database (slower but durable). The choice of store directly affects your scaling strategy -- every server instance needs access to the same store.
The Strengths
- Instant revocation: Delete the session from the store, and the user is immediately logged out. No waiting for expiry.
- Small cookie: The client only carries a random string (32-64 bytes), not the actual user data.
- Server control: You can update session data (change roles, permissions) without the user doing anything.
- No sensitive data in transit: The cookie is just an opaque ID -- the actual user data stays on the server.
The Costs
- Server state: Every active user consumes memory on the server or a store lookup per request.
- Scaling complexity: With multiple server instances, they all need access to a shared session store (usually Redis).
- Store dependency: If Redis goes down, every user is effectively logged out.
How JWTs Work
A JSON Web Token is a signed blob of JSON. Instead of storing user data on the server, you encode it into the token itself, sign it with a secret key, and hand it to the client. When the client sends it back, the server verifies the signature and trusts the claims inside.
A JWT has three parts:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiJ9.SflKxwRJ...
│ header │ payload │ signature │
On every subsequent request:
Client → GET /api/profile
Authorization: Bearer eyJhbG...
Server → Decode the JWT header and payload
→ Verify the signature using secret key
→ Signature valid? Trust the claims: { sub: 42, role: "admin" }
→ No database lookup needed
→ Return user's profile data
The key insight of JWTs: the server doesn't need to look anything up. It verifies the signature mathematically using its secret key. If the signature checks out, the claims in the payload are trustworthy because only someone with the secret key could have produced that signature.
The Strengths
- No server state: Zero storage, zero lookups. CPU-only verification.
- Easy horizontal scaling: Any server instance can verify the token independently. No shared store needed.
- Cross-service authentication: Microservices can verify the token without calling the auth service.
- Works beyond browsers: Mobile apps, CLIs, and third-party integrations handle tokens easily.
The Costs
- No instant revocation: Once issued, a JWT is valid until it expires. You can't "delete" it from the server because the server never stored it.
- Larger payload: Every request carries the full token (typically 500-1500 bytes vs 32 bytes for a session cookie).
- Stale data: If a user's role changes, the JWT still carries the old role until it expires and gets refreshed.
- Token storage on client: Where do you store it? localStorage is vulnerable to XSS. Cookies work but then you're back to cookie-based auth with extra complexity.
The Revocation Problem
This is the biggest practical difference, and it's the one most blog posts gloss over. Let's make it concrete.
Scenario: Your admin revokes a user's access because they violated terms of service. What happens next?
| Action | Sessions | JWT |
|---|---|---|
| Admin revokes access | Delete session from Redis | ...nothing to delete on server |
| User's next API request | Session not found → 401 Unauthorized | Signature still valid → 200 OK (user still has access!) |
| Time to effective revocation | Immediate | Up to token lifetime (15min-1hour) |
| Mitigation | None needed -- it just works | Need a token blocklist (which re-introduces server state) |
⚠️ The JWT revocation paradox
If you need instant revocation with JWTs, you typically add a blocklist that's checked on every request. But now you're doing a store lookup on every request -- which is exactly what sessions do. You've essentially rebuilt sessions with extra steps and a larger cookie.
This doesn't mean JWTs are bad. It means that if instant revocation is a hard requirement, you need to understand the trade-off you're making.
Try It Yourself
Use the simulator below to walk through both authentication flows. Log in, make requests, then try revoking access to see the critical difference.
Try these scenarios:
- Basic flow: Login, then make several API requests. Notice the Session strategy does a store lookup every time, while JWT does zero lookups.
- Revocation test: Login, make a request (works). Revoke access. Make another request. With sessions, the user is immediately locked out. With JWT, the token still works!
- Expiration: Login, expire the token. Both strategies correctly reject the next request, but for different reasons (session gone from store vs JWT exp claim in the past).
Where to Store JWTs on the Client
If you choose JWTs, you face a second decision: where does the client store the token?
| Storage | XSS Vulnerable | CSRF Vulnerable | Auto-sent |
|---|---|---|---|
| localStorage | Yes -- JS can read it | No | No -- manual header |
| sessionStorage | Yes -- JS can read it | No | No -- manual header |
| HttpOnly Cookie | No -- JS can't access it | Yes (without SameSite) | Yes -- browser sends it |
| HttpOnly + SameSite=Strict | No | No | Yes |
✅ The pragmatic choice
HttpOnly cookies with SameSite=Strict (or Lax) give you the best of both worlds: the browser handles storage and transmission automatically, JavaScript can't steal the token via XSS, and SameSite prevents CSRF. This is the approach most security-conscious teams use.
But wait -- if you're putting the JWT in a cookie and letting the browser send it automatically... that looks a lot like a session cookie. The difference is that the server still verifies the JWT's signature instead of doing a store lookup. But you've lost the "works with non-browser clients" advantage.
The Scaling Argument
The strongest case for JWTs is horizontal scaling. Let's walk through what each strategy requires:
Every server must query Redis on every authenticated request. Redis becomes a critical dependency and potential bottleneck.
No shared state. Each server independently verifies the token using the shared secret. This is genuinely simpler at scale -- no Redis cluster to manage, no session store failover to worry about.
But: Redis is fast. A session lookup in Redis takes ~0.5ms. For most applications, this isn't the bottleneck. The scaling argument for JWTs is real but often premature optimization.
The Decision Framework
What type of application are you building?
The Refresh Token Pattern
In practice, most JWT implementations use two tokens:
- Access token: Short-lived (5-15 minutes). Sent with every request. Not revocable.
- Refresh token: Long-lived (days to weeks). Stored securely. Used only to get new access tokens. Stored server-side (like a session) for revocation.
User logs in
→ Server returns: access_token (15min) + refresh_token (7 days)
→ Refresh token stored in server DB for revocation
User makes API request
→ Sends access_token in header
→ Server verifies signature (no lookup)
Access token expires
→ Client sends refresh_token to /auth/refresh
→ Server checks refresh_token against DB (revocable!)
→ Returns new access_token
Admin revokes user
→ Delete refresh_token from DB
→ User's current access_token works for up to 15 more minutes
→ After that, refresh fails → user is logged out
💡 The hybrid reality
This refresh token pattern is essentially sessions + JWTs combined. The refresh token is a session (server-stored, revocable). The access token is a JWT (stateless, short-lived). Most production JWT systems work this way -- they're not purely stateless.
Common Mistakes
Mistake 1: Storing sensitive data in JWT payloads
JWTs are signed, not encrypted. Anyone can decode the payload (it's just base64). Don't put passwords, internal IDs that reveal business logic, or PII that shouldn't be exposed in transit.
// WRONG: sensitive data in JWT
{
"sub": "42",
"email": "alice@company.com",
"ssn": "123-45-6789", // Anyone can decode this!
"internal_tier": "enterprise_discount_40pct"
}
// RIGHT: minimal claims
{
"sub": "42",
"role": "user",
"exp": 1710000000
}Mistake 2: Long-lived JWTs without refresh tokens
A JWT with a 30-day expiry that you can't revoke is a security nightmare. If the token leaks, the attacker has 30 days of access with no way to stop them. Use short access tokens (15 min) with revocable refresh tokens.
Mistake 3: Implementing your own JWT library
Cryptography is hard to get right. Use established libraries (jsonwebtoken for Node, PyJWT for Python, etc.) that handle signature verification, algorithm verification, and timing attacks correctly.
Mistake 4: Not validating the alg header
JWTs specify their signing algorithm in the header. A classic attack changes alg to none, bypassing signature verification. Always validate the algorithm server-side and reject unexpected values.
Key Takeaways
✅ The mental model
Sessions are like a coat check: you give the restaurant your coat, they give you a ticket. JWT is like a wristband at a concert: the wristband itself proves you paid. The coat check can take your coat back anytime (revocation). The wristband works until the concert ends (expiration).
The decisions that matter:
- Default to sessions for traditional web apps. They're simpler, revocable, and well-understood.
- Use JWTs when you need stateless verification across multiple services, or when your clients aren't browsers.
- Short-lived access + revocable refresh is the pattern that most production JWT systems converge on. It's sessions and JWTs combined.
- Token storage matters: HttpOnly cookies with SameSite protection, regardless of whether the token is a session ID or a JWT.
- The scaling argument is real but overblown: Redis handles millions of session lookups per second. Most applications will never outgrow it.
Don't choose JWTs because they're "modern" or because a blog post said sessions are "old school." Choose based on your actual requirements: Do you need instant revocation? Stateless cross-service verification? Non-browser clients? Those are the questions that matter.
References
- RFC 7519: JSON Web Token -- The JWT specification
- OWASP: Session Management Cheat Sheet -- Best practices for session security
- OWASP: JSON Web Token Cheat Sheet -- JWT security considerations
- Auth0: Token Storage -- Where to store tokens safely
- Critical vulnerabilities in JSON Web Token libraries -- Why the
alg: noneattack matters