JWT vs Session Tokens: Key Differences, Security Trade-offs, and When to Use Each

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.

Session vs JWT authentication flow Session flow: client logs in, server stores session in DB and returns a cookie. Client uses the cookie on each request; server must look up the DB every time. JWT flow: client logs in, server signs a token and returns it. Client sends the token on each request; server verifies the signature locally with no DB lookup. Session — stateful · server stores and looks up state on every request Client Server DB / Redis POST /login store session Set-Cookie: sid=abc123 GET /profile · Cookie: sid=abc123 look up session 200 { user data } JWT — stateless · server verifies signature locally, zero DB lookups Client Server No DB ✓ POST /login signs JWT · no DB write 200 { token: "eyJ..." } GET /profile · Bearer: eyJ... verifies signature ✓ · no DB 200 { user data }
Session requires a database lookup on every request; JWT does not. In the session flow (top) the server writes the session to a DB/Redis store at login and reads it back on each request. In the JWT flow (bottom) the server signs a token at login and verifies its signature locally on every request — zero database lookups.

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:

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

JWT Decoder Is it safe to decode JWTs online?

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.

About the author

Pasindu Ishan is a software developer based in Sri Lanka. He builds privacy-first developer tools at JSON Dev Tools.