Quick answer
Sessions store user state on the server and hand the client an opaque ID (cookie); the server looks up that state on every request. JWTs encode all state inside a signed token the client holds — the server verifies the signature with no database lookup. Sessions are stateful; JWTs are stateless.
How each model works
The fundamental difference is where user state lives: a session keeps it on the server and gives the client only a reference, while a JWT puts the state itself inside the token the client carries.
Side-by-side comparison
| Property | Session token | JWT |
|---|---|---|
| State storage | Server (DB / Redis) | Client (token payload) |
| Verification | DB lookup per request | Cryptographic signature check (no DB) |
| Revocation | Instant — delete the session record | Hard — requires a blacklist or short expiry |
| Horizontal scaling | Requires shared session store | Any server verifies independently |
| Token size | Small (~32 bytes) | Larger (~200–500 bytes with claims) |
| Cross-domain / microservices | Awkward (cookies are origin-bound) | Natural (Bearer header works anywhere) |
| Payload visibility | Opaque to client | Base64-decodable (not encrypted by default) |
| Common transport | HttpOnly cookie | Authorization: Bearer header or HttpOnly cookie |
| Standard library | express-session, Flask-Session, Django sessions | jsonwebtoken (Node), PyJWT (Python), Auth0 |
What is inside a JWT
A JWT is three base64url-encoded JSON objects joined by dots: header.payload.signature. The header names the algorithm; the payload carries the claims; the signature is computed over the header and payload so any tampering invalidates the token.
# Decode a JWT without a library (Python)
import base64, json
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc1MDAwMDAwMH0.SIGNATURE"
header_b64, payload_b64, sig = token.split(".")
def decode_part(b64):
# JWT uses unpadded base64url — add padding back
pad = 4 - len(b64) % 4
return json.loads(base64.urlsafe_b64decode(b64 + "=" * pad))
print(decode_part(header_b64))
# {'alg': 'HS256', 'typ': 'JWT'}
print(decode_part(payload_b64))
# {'sub': 'user_123', 'role': 'admin', 'exp': 1750000000}
# Anyone with the token can read this — it is NOT encrypted
You can also paste any JWT into the JWT Decoder to inspect its claims and expiry instantly.
Creating a JWT in Node.js
You issue a JWT with jwt.sign() and verify it with jwt.verify() from the jsonwebtoken package. Signing happens once at login; verification runs on every protected request and throws if the token is expired or tampered with.
const jwt = require("jsonwebtoken");
const SECRET = process.env.JWT_SECRET; // keep this secret!
// Sign — issued at login
function issueToken(userId, role) {
return jwt.sign(
{ sub: userId, role },
SECRET,
{ algorithm: "HS256", expiresIn: "15m" }
);
}
// Verify — called on every protected request
function verifyToken(token) {
try {
return jwt.verify(token, SECRET); // throws if expired or tampered
} catch (err) {
return null; // treat as unauthenticated
}
}
const token = issueToken("user_123", "admin");
const payload = verifyToken(token);
console.log(payload.sub); // "user_123"
Session-based auth in Node.js (express-session)
Session auth stores state server-side in a shared store like Redis and tracks the user with a signed cookie. Logging out is a single req.session.destroy() call — the record is deleted and the token is instantly invalid, which is the capability JWTs lack.
const express = require("express");
const session = require("express-session");
const RedisStore = require("connect-redis").default;
const { createClient } = require("redis");
const app = express();
const redisClient = createClient();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // not accessible via JS
secure: true, // HTTPS only
sameSite: "strict",
maxAge: 24 * 60 * 60 * 1000 // 1 day
}
}));
app.post("/login", (req, res) => {
// after validating credentials...
req.session.userId = "user_123";
req.session.role = "admin";
res.json({ ok: true });
});
app.post("/logout", (req, res) => {
req.session.destroy(); // instantly revoked
res.clearCookie("connect.sid");
res.json({ ok: true });
});
The revocation problem with JWTs
The hardest part of JWT-based auth is logout. Because the token is self-contained, a server cannot "delete" it — it remains valid until its exp claim passes. The standard workarounds:
- Short expiry + refresh tokens — access tokens expire in 5–15 minutes; a long-lived refresh token (stored in the database) issues new access tokens. Revoking the refresh token effectively logs the user out within one expiry window.
- Token blacklist (denylist) — on logout, store the token's
jti(JWT ID) in Redis with a TTL equal to the token's remaining lifetime. Check the denylist on every request. This reintroduces server state but keeps the verification path fast. - Very short expiry, no refresh — for low-risk contexts, an expiry of 1–5 minutes means a stolen token is quickly useless. Only practical when the client re-authenticates silently.
HS256 vs RS256: which signing algorithm to use
HS256 uses a single shared secret. Any service that verifies tokens must hold the secret — a leak compromises the entire system and all services must rotate keys together.
RS256 uses an asymmetric key pair. The auth server signs with the private key; all other services verify with the public key. The public key can be published openly (via a JWKS endpoint) without any security risk. This is the right choice when multiple services or third parties verify your tokens.
# RS256 signing in Python with PyJWT
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
from pathlib import Path
private_key = serialization.load_pem_private_key(
Path("private.pem").read_bytes(),
password=None,
backend=default_backend()
)
public_key = serialization.load_pem_public_key(
Path("public.pem").read_bytes(),
backend=default_backend()
)
# Auth server — signs with private key
token = jwt.encode({"sub": "user_123", "role": "admin"}, private_key, algorithm="RS256")
# Any service — verifies with public key (never sees private key)
payload = jwt.decode(token, public_key, algorithms=["RS256"])
print(payload) # {'sub': 'user_123', 'role': 'admin'}
Where to store a JWT in the browser
The safest place to store a JWT in the browser is an HttpOnly, Secure, SameSite=Strict cookie — not localStorage. Token storage is the most debated JWT question; here are the two options and their trade-offs:
| Storage | XSS risk | CSRF risk | Verdict |
|---|---|---|---|
localStorage |
High — any JS can read it | Low — not sent automatically | Avoid for sensitive apps |
HttpOnly cookie |
Low — JS cannot read it | Mitigated by SameSite=Strict | Recommended |
An HttpOnly cookie with Secure; SameSite=Strict gives you the best of both worlds — it is not readable by JavaScript (no XSS exfiltration) and is not sent on cross-site requests (no CSRF). At the transport and storage level, security becomes comparable to a session cookie — but the two are not fully equivalent: a session can still be revoked centrally by deleting the server record, whereas a JWT’s security continues to depend on its token-lifecycle design (short expiry, refresh rotation, and any denylist you maintain).
When to use sessions vs JWTs
Choose by your revocation needs and how many independent services must verify identity. Use this if/then table as a quick decision rule:
| If you need to… | Use |
|---|---|
| Revoke access instantly on logout (banking, healthcare, admin) | Session |
| Verify identity across many stateless services with no shared store | JWT |
| Build a traditional server-rendered web app | Session |
| Authenticate mobile apps or third-party API clients | JWT |
| Support SSO across separate domains (no shared cookie) | JWT |
| Let multiple services verify tokens without a shared secret | JWT (RS256) |
| Keep the token small and the model simple | Session |
| Limit stolen-token exposure while staying stateless | JWT + refresh rotation |
How to choose between JWT and session tokens
Work through these five steps in order — each one narrows the decision:
- Assess revocation requirements. If users must be able to log out instantly and have their access revoked immediately (e.g. banking, healthcare, admin tools), use sessions — they can be deleted from the server store the moment logout happens. JWTs cannot be revoked without additional infrastructure.
- Evaluate your deployment topology. If you run many stateless API server instances behind a load balancer and want to avoid a shared session store, JWTs reduce infrastructure coupling. If you already run Redis or a database for other reasons, a session store adds negligible cost.
- Consider client diversity. If multiple clients — mobile apps, third-party services, microservices — need to verify user identity without calling back to the auth server, JWTs are the natural fit. Bearer tokens in Authorization headers work across any HTTP client.
- Choose a signing algorithm. For a single-service app use HS256 (simpler). For microservices where multiple services verify tokens, use RS256 — services hold the public key and never touch the private key. Avoid the none algorithm, and always validate the alg header.
- Set expiry and rotation policy. Short-lived access tokens (15 minutes) with a refresh token pattern give the best security-to-convenience ratio for JWTs. Sessions should have an idle timeout and an absolute maximum lifetime. Rotate refresh tokens on every use (refresh token rotation) and revoke the family on reuse detection.
Inspect any JWT instantly
Paste a token into the decoder to see its header, payload claims, expiry timestamp, and algorithm — everything runs in your browser with no data sent to any server.
Frequently Asked Questions
What is the main difference between JWT and session tokens?
A session token is an opaque random ID the server maps to stored user state (in a database or memory store). A JWT is self-contained — it encodes all user state inside a signed token the client holds. The server needs no database lookup to verify a JWT, just a cryptographic signature check. Sessions are stateful; JWTs are stateless.
Can you invalidate a JWT before it expires?
Not natively. Because JWTs are stateless, there is no central record to delete. The standard workarounds are: a token blacklist (denylist) in Redis keyed by jti, or short-lived access tokens (5–15 minutes) paired with a revocable refresh token. Both partially reintroduce server state, which is the core trade-off of the stateless JWT model.
Where should I store a JWT in the browser?
Use an HttpOnly; Secure; SameSite=Strict cookie. This prevents JavaScript from reading the token (blocking XSS exfiltration) and the SameSite attribute blocks cross-site request forgery. Storing JWTs in localStorage is convenient but exposes them to any injected script.
What is the difference between HS256 and RS256 for JWT signing?
HS256 uses a single shared secret — every service that verifies tokens must hold it. RS256 uses an asymmetric key pair — the auth server signs with the private key; other services verify with the public key, which can be published safely via a JWKS endpoint. Use RS256 in microservice environments where multiple services verify tokens.
When should I use sessions instead of JWTs?
Prefer sessions when instant revocation is required (logout must work immediately), when building a traditional server-rendered app, or when token size matters (JWTs grow with every claim). Sessions are also simpler to reason about and are the right default for most web applications that don't span multiple services.
What are refresh tokens and why do JWTs need them?
Refresh tokens are long-lived, server-stored credentials exchanged for new short-lived access tokens (JWTs). Short access tokens (5–15 min) limit the window of abuse from a stolen token. The refresh token can be revoked at any time, restoring meaningful logout. This pattern — short-lived JWT + revocable refresh token — is the recommended approach for most JWT-based systems.
Do JWTs scale better than sessions?
JWTs scale horizontally without a shared store — any server verifies tokens independently using only the key. Sessions require all servers to reach a shared store (e.g. Redis), adding a network hop. However, a fast distributed cache makes session scaling practical, and a JWT blacklist reintroduces state anyway. The scaling advantage of JWTs is often overstated.
Are JWTs encrypted?
No, not by default. A standard JWT (JWS) is base64url-encoded and signed — the payload is readable by anyone who holds the token. Never put passwords, PII, or secrets in the JWT payload. If confidentiality is required, use JWE (JSON Web Encryption), though this is rare in practice.