Skip to main content

Command Palette

Search for a command to run...

Sessions, Cookies, and JWT: Choosing the Right Authentication Strategy

Published
13 min read
Sessions, Cookies, and JWT: Choosing the Right Authentication Strategy

Authentication is one of those things every web application needs, yet the implementation decision — sessions or tokens — trips up developers more than it should. The two approaches are genuinely different in philosophy, not just syntax. Getting this choice wrong early means refactoring auth infrastructure later, which is painful.

This article explains what sessions, cookies, and JWTs actually are, how each authentication flow works, and how to decide which one fits your situation.


What Sessions Are

A session is a server-side record that tracks the state of a user between requests.

HTTP is stateless by design. Each request from a browser to a server is independent — the server has no built-in memory of previous requests. Sessions are the workaround: the server creates a record when a user logs in, stores it somewhere (memory, a database, Redis), and hands the user an identifier. On every subsequent request, the user sends that identifier back, and the server looks up the session record to verify who they are.

The session itself lives on the server. The client only ever holds the key.

Server Memory / Redis / Database:
┌────────────────────────────────────────────────────┐
│  Session ID: a3f9b2c1d4e5                          │
│  ├── userId: 42                                    │
│  ├── email: alice@example.com                      │
│  ├── role: admin                                   │
│  └── expires: 2024-12-01T10:00:00Z                 │
└────────────────────────────────────────────────────┘

The session ID is meaningless on its own — it's just a random string. Without the server's storage, it gives an attacker nothing useful.


What Cookies Are

A cookie is a small piece of data the server sends to the browser, which the browser stores and automatically includes in every subsequent request to that domain.

Cookies are the transport mechanism most commonly used to carry session IDs (and sometimes tokens). They are not authentication themselves — they are just storage with automatic delivery.

Server response header:
Set-Cookie: sessionId=a3f9b2c1d4e5; HttpOnly; Secure; SameSite=Strict; Max-Age=3600
Every subsequent browser request to that domain:
Cookie: sessionId=a3f9b2c1d4e5

The browser handles this automatically. You don't write JavaScript to attach the cookie — it just shows up on every request.

Flag What it does
HttpOnly Blocks JavaScript from reading the cookie — protects against XSS
Secure Cookie only sent over HTTPS
SameSite=Strict Cookie not sent on cross-site requests — protects against CSRF
Max-Age Expiry in seconds

These flags are not optional for auth cookies. A session ID in a cookie without HttpOnly is readable by any JavaScript on your page, including injected scripts.


What JWT Tokens Are

A JSON Web Token (JWT) is a self-contained, signed package of data. Instead of storing user state on the server and giving the client a key, you encode the user state directly into a token and give it to the client. The server verifies the token's signature on every request — no database lookup required.

A JWT has three parts, separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzAxNDIyNDAwLCJleHAiOjE3MDE0MjYwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header.Payload.Signature

Decoded, the three parts look like this:

// Header — algorithm and token type
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload — your actual data (called "claims")
{
  "userId": 42,
  "email": "alice@example.com",
  "role": "admin",
  "iat": 1701422400,
  "exp": 1701426000
}

// Signature — HMAC of header + payload using your secret key
// If anyone tampers with the payload, the signature won't match

The signature is what makes this trustworthy. Without knowing the server's secret key, you cannot produce a valid signature. The server verifies every incoming token by recomputing the signature — if it matches, the payload is trusted. No database read required.

The payload is base64-encoded, not encrypted. Anyone can decode and read it. Never put passwords, card numbers, or sensitive data in a JWT payload.


Session Authentication Flow

1. User submits login form (POST /login)
        │
        ▼
2. Server validates credentials against database
        │
        ▼
3. Server creates a session record in storage
   ┌─────────────────────────────┐
   │  sessionId: a3f9b2c1d4e5   │
   │  userId: 42                 │
   │  expires: +1hr              │
   └─────────────────────────────┘
        │
        ▼
4. Server sends session ID to client via Set-Cookie header
        │
        ▼
5. Browser stores cookie, sends it automatically on every request
        │
        ▼
6. Server receives request with cookie
        │
        ▼
7. Server looks up session ID in storage
   └── Found + not expired? → User is authenticated
   └── Not found / expired? → 401 Unauthorized

A basic Express session implementation:

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();
await redisClient.connect();

app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: "strict",
      maxAge: 1000 * 60 * 60, // 1 hour
    },
  })
);

// Login route
app.post("/login", async (req, res) => {
  const { email, password } = req.body;
  const user = await db.findUserByEmail(email);

  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  // Store user info in session (server-side)
  req.session.userId = user.id;
  req.session.role = user.role;

  res.json({ message: "Logged in" });
});

// Protected route
app.get("/dashboard", (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: "Not authenticated" });
  }
  res.json({ userId: req.session.userId });
});

// Logout — destroy the session record
app.post("/logout", (req, res) => {
  req.session.destroy(() => {
    res.clearCookie("connect.sid");
    res.json({ message: "Logged out" });
  });
});

JWT Authentication Flow

1. User submits login form (POST /login)
        │
        ▼
2. Server validates credentials against database
        │
        ▼
3. Server creates and signs a JWT with user data + secret key
   ┌──────────────────────────────────────────┐
   │  Payload: { userId: 42, role: "admin" }  │
   │  Signed with: SECRET_KEY                 │
   └──────────────────────────────────────────┘
        │
        ▼
4. Server sends JWT to client
   (response body, or Set-Cookie header)
        │
        ▼
5. Client stores JWT
   (localStorage, or HttpOnly cookie)
        │
        ▼
6. Client sends JWT on every request
   Authorization: Bearer <token>
        │
        ▼
7. Server receives request
        │
        ▼
8. Server verifies JWT signature using secret key
   └── Valid signature + not expired? → Decode payload, user is authenticated
   └── Invalid / expired? → 401 Unauthorized
   (No database lookup at this step)

A basic Express JWT implementation:

const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");

const app = express();
app.use(express.json());

const SECRET = process.env.JWT_SECRET;

// Login route — issue a token
app.post("/login", async (req, res) => {
  const { email, password } = req.body;
  const user = await db.findUserByEmail(email);

  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  const token = jwt.sign(
    { userId: user.id, role: user.role }, // payload
    SECRET,
    { expiresIn: "1h" }
  );

  res.json({ token });
});

// Middleware to verify JWT on protected routes
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return res.status(401).json({ error: "No token provided" });
  }

  const token = authHeader.split(" ")[1];

  try {
    const decoded = jwt.verify(token, SECRET);
    req.user = decoded; // { userId, role, iat, exp }
    next();
  } catch (err) {
    return res.status(401).json({ error: "Invalid or expired token" });
  }
}

// Protected route
app.get("/dashboard", authenticate, (req, res) => {
  res.json({ userId: req.user.userId, role: req.user.role });
});

// Logout — handled client-side (just delete the token)
// The server has no record to invalidate

Stateful vs Stateless Authentication

This is the core philosophical difference between the two approaches.

Session-based auth is stateful. The server stores session data. The server knows who is logged in, because it has the records. To verify a request, it reads from storage.

JWT-based auth is stateless. The server stores nothing. The token carries everything the server needs to verify the request. To verify a request, it does math (signature verification) — not a database read.

Stateful (Session):
  Client ──── session ID ────► Server ──── lookup ────► Storage
                                                           │
                                          ◄──── data ─────┘

Stateless (JWT):
  Client ──── token ────► Server ──── verify signature ────► Done
                          (no storage involved)

The practical consequence: with sessions, every server in a cluster needs access to the same session storage. With JWTs, any server with the secret key can verify any token independently.


Session vs JWT: Direct Comparison

Consideration Session-Based JWT-Based
Where state lives Server (DB, Redis, memory) Client (the token itself)
Server lookup per request Yes — reads from session store No — verifies signature only
Scalability Requires shared session storage across servers Stateless — any server can verify
Logout / revocation Instant — delete the session record Hard — token stays valid until expiry
Token invalidation Built-in Requires a token blocklist (adds complexity)
Data freshness Always current (server controls it) Stale until token expires (payload is fixed at issue time)
Payload visibility Hidden — only on server Readable by anyone who has the token
Cookie-based storage Natural fit Possible, but often stored in localStorage
CSRF risk Yes, if using cookies (mitigated by SameSite) No, if using Authorization header
XSS risk Lower (HttpOnly cookie) Higher (if stored in localStorage)
Best for Traditional web apps, single-server setups APIs, microservices, mobile clients

The Revocation Problem with JWTs

This deserves its own section because it catches people off guard.

With sessions, logout is trivial:

// Session logout — immediately effective
req.session.destroy();
// The session ID is now gone. Even if the client sends it again, the server
// won't find it. The user is logged out for real.

With JWTs, logout on the server side is not possible by default:

// JWT "logout" — client just deletes their copy of the token
// But if someone else has a copy of the token, it still works until expiry

// The only real server-side solution is a blocklist
const revokedTokens = new Set();

app.post("/logout", authenticate, (req, res) => {
  revokedTokens.add(req.user.jti); // jti = unique token ID
  res.json({ message: "Logged out" });
});

function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(" ")[1];
  const decoded = jwt.verify(token, SECRET);

  if (revokedTokens.has(decoded.jti)) {
    return res.status(401).json({ error: "Token revoked" });
  }

  req.user = decoded;
  next();
}

The blocklist works, but now you're managing server-side state again — which partially defeats the purpose of going stateless. This is a real tradeoff to acknowledge before choosing JWTs.

A practical middle ground: short-lived access tokens (15 minutes) paired with a refresh token stored in an HttpOnly cookie. You can't instantly revoke access tokens, but the blast radius is limited to 15 minutes.


When to Use Sessions

Session-based authentication is the right default for:

Traditional server-rendered web apps — Express + EJS, Rails, Django. Cookies are native to the browser, the session store is right there, and there's no need for the client to handle tokens.

Applications where instant revocation matters — banking, admin panels, anything where "log out all devices" or "revoke on password change" is a hard requirement.

Single-server or simple deployments — if you're not running a distributed system, there's no benefit to stateless tokens.

When you want simpler security defaults — cookies with HttpOnly and SameSite are harder to steal via XSS than a token sitting in localStorage.

// When a user changes their password, invalidate all sessions immediately
await db.deleteAllSessionsForUser(userId);
// Every device is logged out the moment this runs

When to Use JWT

JWT-based authentication is the right choice for:

APIs consumed by mobile or third-party clients — clients that can't use cookies easily, or clients that need to authenticate against multiple domains.

Microservices and distributed systems — each service can verify tokens independently without a shared session store. No inter-service calls for auth.

Client ──► API Gateway (verifies JWT) ──► Service A
                                     └──► Service B
                                     └──► Service C
// All three services trust the same token with no shared storage

Cross-domain authentication — cookies are domain-scoped. JWTs sent via Authorization header work across any domain.

Stateless serverless functions — Lambda functions, Cloudflare Workers, and similar environments have no persistent memory. JWT verification is just a computation — no connection to a session store needed.

Short-lived machine-to-machine auth — service accounts, API keys with expiry, build system tokens.


Storage: Where to Keep the JWT on the Client

This is the most debated implementation question. The two options are localStorage and an HttpOnly cookie, and they have opposite tradeoff profiles:

localStorage:
  + Simple to implement
  + Works across tabs
  - Readable by any JavaScript on the page
  - Vulnerable to XSS: one injected script steals all tokens

HttpOnly cookie:
  + Inaccessible to JavaScript — XSS-resistant
  + Browser handles attachment automatically
  - Requires CSRF protection (use SameSite=Strict or CSRF tokens)
  - Slightly more setup

For most production applications, HttpOnly cookie is the safer choice, even for JWTs. The argument that "cookies are for sessions and headers are for JWTs" is convention, not a technical rule.


Quick Decision Guide

Are you building a traditional server-rendered web app?
  └── Yes → Sessions + cookies

Do you need instant logout / revocation on all devices?
  └── Yes → Sessions

Are you building a REST API for mobile or third-party clients?
  └── Yes → JWT

Are you running microservices or serverless infrastructure?
  └── Yes → JWT

Do you need cross-domain authentication (different origins)?
  └── Yes → JWT

Are you not sure and want a safe default?
  └── Sessions — they're simpler to reason about and easier to get right

Conclusion

Sessions and JWTs solve the same problem — proving that a request comes from an authenticated user — but they do it in opposite ways. Sessions keep state on the server and hand the client a key. JWTs encode state into a signed token that the client carries.

Neither is universally better. Sessions give you instant control (revocation, fresh data, simple logout) at the cost of server-side storage. JWTs give you scalability and portability at the cost of revocation complexity and client-side state management.

For most web applications, sessions are the simpler and safer starting point. For APIs, distributed systems, and mobile clients, JWTs are often the more practical fit.

Know the tradeoffs. Pick the one that matches your infrastructure and requirements — not the one that's trending.