Skip to content
crypto2026-07-025 min read

Every web app needs authentication, and the choice often gets reduced to "JWT or sessions?" That decision matters more than it looks. Get it wrong and you end up unable to revoke stolen tokens, leak sensitive data by accident, blow up the complexity of inter-service auth, or force a simple web app into a stateless architecture that doesn't fit.

This guide breaks down how each mechanism works, where each is strong, and the failure modes you'll hit.

Session Cookies: Server Remembers You

Sessions are the classic approach. The flow:

  1. User logs in, server validates credentials
  2. Server creates a session object, stores it in memory, Redis, or a database
  3. Server sends the session ID (a random string) to the browser via Set-Cookie
  4. Subsequent requests include the cookie automatically, server looks up the session by ID
  5. Logout destroys the session object
// Express + express-session, typical setup
const session = require('express-session');

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, httpOnly: true, sameSite: 'lax' }
}));

app.post('/login', (req, res) => {
  // validate user...
  req.session.userId = user.id;
  res.send('logged in');
});

app.post('/logout', (req, res) => {
  req.session.destroy(() => res.send('logged out'));
});

Key properties:

  • Server-side state: every active session consumes storage (memory or external store)
  • Instantly revocable: delete from storage and it's gone, no waiting for expiry
  • Cookie-based transport: browser handles sending, cross-origin needs SameSite tuning
  • Data stays server-side: cookie is just an ID, business data never leaves the server

JWT: Self-Contained Token

JWT (JSON Web Token) encodes the auth state in the token itself. The structure is three Base64URL segments separated by .:

header.payload.signature
  • header: algorithm type (e.g. {"alg":"HS256","typ":"JWT"})
  • payload: claims like user ID, role, expiry
  • signature: HMAC or RSA signature over header.payload, prevents tampering
// Issue a JWT
const jwt = require('jsonwebtoken');
const token = jwt.sign(
  { userId: 42, role: 'admin' },
  process.env.JWT_SECRET,
  { expiresIn: '1h' }
);

// Verify
const payload = jwt.verify(token, process.env.JWT_SECRET);

Key properties:

  • Stateless: server stores nothing, signature verification is sufficient
  • Self-contained: payload carries user info, no DB lookup needed
  • Cross-origin friendly: sent in Authorization: Bearer ... header, no cookie restrictions
  • Hard to revoke: once issued, valid until expiry

The Core Tradeoff

The two are philosophically opposite. Sessions put trust on the server. JWT puts trust in the token. This produces a cascade of different tradeoffs:

| Dimension | Session Cookie | JWT | |-----------|----------------|-----| | State | Server stateful | Fully stateless | | Revocation | Immediate | Needs blacklist or short TTL | | Cross-service | Shared session store | Just verify signature | | Cross-origin | Cookie config needed | Header works | | Size | cookie ~20 bytes | token ~500 bytes | | Default safety | Higher (HttpOnly cookie) | Implementation-dependent | | Data confidentiality | Private to server | Payload readable by client |

The last row is the most overlooked trap.

Common JWT Security Pitfalls

1. The payload is Base64, not encrypted

Anyone with the JWT can decode the payload — paste one into a JWT Decoder and every claim shows up in plain text. Putting sensitive data (passwords, API keys, personal info) in the payload is effectively plaintext exposure:

# Any JWT decodes like this, no key needed
echo "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ" | base64 -d
# {"userId":42,"role":"admin"}

If you need confidentiality, use JWE (JSON Web Encryption), or keep sensitive data out of the JWT entirely.

2. The alg: none attack

Early JWT libraries allowed the alg: none header, meaning the signature was skipped. An attacker could forge any token and claim it didn't need verification. Modern libraries refuse this by default, but misconfiguration (reading the algorithm from the header without an allowlist) still triggers it.

// Dangerous: allow any algorithm
jwt.verify(token, secret);  // legacy default in some old libraries

// Safe: pin the algorithm explicitly
jwt.verify(token, secret, { algorithms: ['HS256'] });

3. You can't revoke a JWT mid-flight

A JWT is valid until it expires. If a user logs out but the token was already intercepted, the attacker keeps using it. Workarounds:

  • Short TTL: access tokens expire in 15 minutes, paired with a refresh token
  • Blacklist: server tracks revoked tokens (this breaks the "stateless" promise)
  • Key rotation: rotate keys periodically to invalidate old tokens (affects all users)

4. Where you store the JWT matters

JWT in localStorage is vulnerable to XSS theft. JWT in an HttpOnly cookie is safer from XSS but you have to handle CSRF. No perfect answer, just tradeoffs based on which attack class you prioritize.

When Sessions Are Right

  • Traditional web apps: server-rendered, same-origin, need instant logout
  • Strong security requirements: finance, healthcare, any case where "kick the user out now" must work
  • Simple systems: single service, no cross-service auth needed
  • Data the client shouldn't see: everything stays server-side

When JWT Is Right

  • Microservices: each service just verifies the signature, no shared session store
  • SPA + mobile API: client manages the token, cross-platform and cross-origin
  • Third-party API calls: service-to-service with token carrying identity claims (prototype them with a JWT Generator)
  • Short-TTL workflows: paired with refresh tokens, access tokens live briefly

Hybrid Patterns

Real systems often mix both. Common shapes:

  • JWT for service-to-service, session for browser: API gateway verifies JWT, browser login uses session
  • Refresh token + access token: refresh token is stateful (revocable), access token is stateless JWT with short TTL
  • Session-to-JWT exchange: login issues a JWT, but the token list lives in a session for batch revocation

Decode And Generate JWTs Locally

Whether you're debugging a token's contents, verifying a signature, or testing JWT issuance logic, you should avoid sending real tokens to an unfamiliar service.

The JWT Decoder and JWT Generator tools perform all operations locally in your browser. Headers and payloads decode in your browser, signature verification uses the Web Crypto API on your machine. Your tokens (especially production tokens that may contain user IDs and permissions) are never transmitted.

# Quick command-line payload decode (no key required)
echo "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ" | base64 -d 2>/dev/null || \
  echo "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ==" | base64 -d

The short version: prefer sessions for traditional web apps. Prefer JWT (with short TTL) for microservices and cross-platform APIs. Never put sensitive data in a JWT payload. If you need instant revocation, don't use pure JWT, use sessions or refresh tokens with a blacklist.


Advertisement