Every new app hits the same fork: JWT or sessions? Both approaches authenticate users, and both are in widespread production use. The difference is where trust lives — in the token itself or in the server. Pick wrong and you either ship a logout that does not work or a distributed system that requires a database lookup on every request. Here is the 2026 guide.
What changed in 2026
- Auth libraries matured. Better Auth, Lucia (v3), and Auth.js handle both patterns with sane defaults — rolling your own session or JWT logic is increasingly unnecessary.
- Edge authentication grew: running JWT verification at the CDN edge (Cloudflare Workers, Vercel Edge) is now a common pattern that eliminates origin latency for auth checks.
- PASETO gained traction as a safer alternative to JWT (no algorithm confusion attacks), but JWTs still dominate by install count.
- Refresh token rotation became the standard (RFC 6749 best practices) — a refresh token is single-use; reuse detection signals a token theft.
Core comparison
| Property |
Sessions |
JWT |
| State lives in |
Server (DB or Redis) |
Client (token payload) |
| Revocation |
Instant (delete the session) |
Cannot revoke until expiry* |
| Server lookup per request |
Yes (session store) |
No (verify signature only) |
| Scales horizontally |
Requires shared session store |
Easy (stateless) |
| Token size |
Small cookie (session ID) |
Larger (encoded claims) |
| Best for |
Monolith, web apps, SaaS |
Microservices, mobile, M2M |
*Unless you maintain a revocation blocklist — which negates the stateless advantage.
Session authentication flow
1. User logs in → server creates session, stores in DB/Redis
2. Server sends session cookie (HttpOnly, Secure, SameSite=Strict)
3. Every request: browser sends cookie → server looks up session
4. Logout: server deletes session row → cookie is immediately invalid
Sessions are simple, revocable, and the right default for most web apps. The database lookup (typically 1–2 ms with Redis) is not the bottleneck in real apps.
JWT authentication flow
1. User logs in → server issues access token (15 min) + refresh token (7 days)
2. Client stores access token in memory (not localStorage)
3. Every request: client sends Authorization: Bearer <token>
4. Server verifies signature — no DB lookup needed
5. On expiry: client uses refresh token to get a new access token
6. Logout: client discards token; server cannot invalidate it until expiry
JWT implementation with short-lived tokens
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
// Issue access token
const token = await new SignJWT({ sub: userId, role: "user" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(secret);
// Verify on each request
const { payload } = await jwtVerify(token, secret);
// payload.sub is the user ID — no DB lookup needed
Refresh token rotation
// On refresh:
// 1. Verify the refresh token
// 2. Check it has not been used before (stored in DB)
// 3. Issue new access token + new refresh token
// 4. Mark old refresh token as used
// 5. If a used token is presented → revoke all tokens for that user (theft signal)
This is the RFC 9068 best practice. A refresh token store is a small Redis set — one row per active session.
How to pick
- Monolithic web app with logout that works → Sessions. Simple, revocable, battle-tested.
- Microservices where each service verifies auth → JWT. No shared session store dependency.
- Mobile + web sharing an API → JWT with short expiry + refresh rotation.
- Machine-to-machine (M2M) API calls → JWT (often with client credentials OAuth2 flow).
- You need to ban a user immediately → Sessions or JWT + blocklist. Pure JWT cannot revoke mid-token.
Common mistakes
Storing JWTs in localStorage. XSS can steal them. Store access tokens in memory; store refresh tokens in HttpOnly cookies.
Long JWT expiry (24h+). A stolen token is valid for the full duration. Keep access tokens to 15 minutes maximum.
Putting sensitive data in the JWT payload. The payload is base64-encoded, not encrypted. Anyone with the token can decode it. Never put passwords, credit card data, or secrets in a JWT.
Not verifying the algorithm header. The alg: "none" attack is a classic JWT footgun. Always explicitly specify the expected algorithm in your verify call.
Sessions without a shared store in horizontal scaling. If session data lives in server memory, users fail auth when routed to a different instance. Use Redis or a DB-backed session store.
What to skip
- Rolling your own JWT library. Use
jose (Web Crypto API, works at edge) or jsonwebtoken (Node.js). Cryptography implementations are easy to get wrong.
- Symmetric JWT secrets shorter than 256 bits. Use
openssl rand -base64 32 to generate a proper secret.
- Sessions stored in signed cookies without server-side invalidation. You lose the ability to revoke on demand.
FAQ
Can I use both JWTs and sessions in the same app?
Yes — a common pattern is sessions for the web frontend and JWTs for the mobile API sharing the same backend.
What is the difference between access tokens and refresh tokens?
Access tokens are short-lived and sent with every request. Refresh tokens are long-lived, stored securely, and only used to mint new access tokens. Never send a refresh token to your API.
Does JWT support roles and permissions?
Yes — include claims like { role: "admin", scopes: ["read:users"] } in the payload. Verify them on each request without a DB lookup.
Is PASETO better than JWT?
For new projects, PASETO eliminates algorithm confusion attacks and has cleaner defaults. But JWT tooling is more mature and widely supported. Either is fine if used correctly.
Where to go next