DEV Community

Cover image for Sessions vs JWT vs Cookies: Understanding Authentication Approaches
Pratham
Pratham

Posted on

Sessions vs JWT vs Cookies: Understanding Authentication Approaches

Three terms that get mixed up constantly — and a clear guide to what each one actually does.


When I first started building login systems, I was drowning in terminology. Sessions. Cookies. Tokens. JWT. Stateful. Stateless. Every tutorial used different words for what seemed like the same thing, and I couldn't figure out how the pieces fit together.

Here's what finally cleared the confusion: cookies, sessions, and JWTs are not the same category of thing. Comparing them is like comparing "envelopes," "letters," and "email" — they're related, but they serve different purposes.

  • A cookie is a transport mechanism — it carries data between client and server
  • A session is a server-side record of who you are
  • A JWT is a self-contained token that proves who you are

Once I understood what each one actually is, the authentication strategies built on top of them made perfect sense. Let me explain it the way it clicked for me in the ChaiCode Web Dev Cohort 2026.


What Are Cookies?

A cookie is a small piece of data that the server sends to the browser. The browser stores it and automatically sends it back with every subsequent request to that server.

Cookies aren't an authentication method — they're a delivery vehicle. They can carry anything: a session ID, a JWT, user preferences, tracking data.

How Cookies Work

1. Client sends login request:
   POST /login  { email: "pratham@dev.in", password: "secret" }
        │
        ↓
2. Server validates credentials and sets a cookie:
   Response headers:
   Set-Cookie: sessionId=abc123; HttpOnly; Secure; Path=/
        │
        ↓
3. Browser STORES the cookie automatically

4. Every future request to this server includes the cookie:
   GET /api/profile
   Cookie: sessionId=abc123    ← browser sends this automatically
        │
        ↓
5. Server reads the cookie and knows who you are
Enter fullscreen mode Exit fullscreen mode

Key Cookie Properties

res.cookie("sessionId", "abc123", {
  httpOnly: true, // Can't be accessed by JavaScript (XSS protection)
  secure: true, // Only sent over HTTPS
  maxAge: 86400000, // Expires in 24 hours (milliseconds)
  sameSite: "strict", // Not sent with cross-site requests (CSRF protection)
});
Enter fullscreen mode Exit fullscreen mode
Property What It Does
httpOnly Browser can't access it via document.cookie — safer
secure Only sent over HTTPS connections
maxAge How long the cookie lives (in milliseconds)
sameSite Controls when cookie is sent with cross-site requests
path Which routes the cookie applies to

Cookies are the envelope. What you put inside (a session ID or a JWT) determines your authentication strategy.


What Are Sessions?

A session is a record stored on the server that tracks information about a logged-in user. When a user logs in, the server creates a session, gives it a unique ID, and sends that ID to the client (usually via a cookie). The client sends the session ID with every request, and the server looks it up to know who the user is.

Session Authentication Flow

┌──────────┐                              ┌──────────────────┐
│  Client  │                              │     Server       │
│ (Browser)│                              │                  │
└────┬─────┘                              │  Session Store:  │
     │                                    │  ┌────────────┐  │
     │  1. POST /login                    │  │ (empty)    │  │
     │     { email, password }            │  └────────────┘  │
     │ ──────────────────────────────→    │                  │
     │                                    │  2. Validate     │
     │                                    │     credentials  │
     │                                    │                  │
     │                                    │  3. Create       │
     │                                    │     session:     │
     │                                    │  ┌────────────┐  │
     │                                    │  │ sid: abc123│  │
     │                                    │  │ user: {...}│  │
     │  4. Set-Cookie: sid=abc123         │  └────────────┘  │
     │ ←──────────────────────────────    │                  │
     │                                    │                  │
     │  5. GET /api/profile               │                  │
     │     Cookie: sid=abc123             │                  │
     │ ──────────────────────────────→    │                  │
     │                                    │  6. Look up      │
     │                                    │     session      │
     │                                    │     abc123       │
     │  7. Response: { user data }        │     → found!     │
     │ ←──────────────────────────────    │                  │
     │                                    │                  │
└────┴─────┘                              └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Characteristics

  • The session data lives on the server (in memory, a database, or Redis)
  • The client only has a session ID — a meaningless string
  • The server must look up the session on every request
  • If the server restarts, sessions in memory are lost (unless stored externally)

Express Example

const express = require("express");
const session = require("express-session");

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

app.use(
  session({
    secret: "my-secret-key",
    resave: false,
    saveUninitialized: false,
    cookie: { maxAge: 24 * 60 * 60 * 1000 }, // 24 hours
  }),
);

// Login — create session
app.post("/login", (req, res) => {
  const { email, password } = req.body;

  // Validate credentials (simplified)
  if (email === "pratham@dev.in" && password === "secret") {
    req.session.user = { id: 1, name: "Pratham", role: "developer" };
    res.json({ message: "Logged in!" });
  } else {
    res.status(401).json({ error: "Invalid credentials" });
  }
});

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

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

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

What Are JWT Tokens?

A JWT (JSON Web Token) is a self-contained token that carries user information inside it. Unlike sessions, the server doesn't need to store anything — all the data the server needs to identify the user is encoded in the token itself.

JWT Structure

A JWT has three parts, separated by dots:

eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6IlByYXRoYW0ifQ.sG5_Kd7vFj2OqR3Xk1mZp8
└──────── Header ────────┘└──────── Payload ──────────────────┘└────── Signature ──────┘
Enter fullscreen mode Exit fullscreen mode
Part Contains Encoded?
Header Algorithm and token type Base64 (readable, not encrypted)
Payload User data (id, name, role, expiry) Base64 (readable, not encrypted)
Signature Verification hash (proves data wasn't tampered) Cryptographic hash

The payload is not encrypted — anyone can decode and read it. The signature ensures that if anyone modifies the payload, the server will detect it.

JWT Authentication Flow

┌──────────┐                              ┌──────────────────┐
│  Client  │                              │     Server       │
│ (Browser/│                              │                  │
│  Mobile) │                              │  No session      │
└────┬─────┘                              │  store needed!   │
     │                                    │                  │
     │  1. POST /login                    │                  │
     │     { email, password }            │                  │
     │ ──────────────────────────────→    │                  │
     │                                    │  2. Validate     │
     │                                    │     credentials  │
     │                                    │                  │
     │                                    │  3. Create JWT:  │
     │                                    │     sign({       │
     │                                    │       id: 1,     │
     │                                    │       name: "P"  │
     │                                    │     }, secret)   │
     │  4. Response: { token: "eyJ..." }  │                  │
     │ ←──────────────────────────────    │                  │
     │                                    │                  │
     │  5. Client STORES token            │                  │
     │     (localStorage / cookie)        │                  │
     │                                    │                  │
     │  6. GET /api/profile               │                  │
     │     Authorization: Bearer eyJ...   │                  │
     │ ──────────────────────────────→    │                  │
     │                                    │  7. Verify JWT   │
     │                                    │     signature    │
     │                                    │     → valid!     │
     │                                    │     Extract user │
     │  8. Response: { user data }        │     from payload │
     │ ←──────────────────────────────    │                  │
     │                                    │                  │
└────┴─────┘                              └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Difference From Sessions

The server doesn't store anything. It creates the token, sends it to the client, and forgets about it. When the client sends it back, the server verifies the signature and reads the payload — no database lookup needed.

Express Example

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

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

const SECRET = "my-jwt-secret-key";

// Login — create JWT
app.post("/login", (req, res) => {
  const { email, password } = req.body;

  if (email === "pratham@dev.in" && password === "secret") {
    const token = jwt.sign(
      { id: 1, name: "Pratham", role: "developer" },
      SECRET,
      { expiresIn: "24h" },
    );
    res.json({ message: "Logged in!", token });
  } else {
    res.status(401).json({ error: "Invalid credentials" });
  }
});

// Middleware — verify JWT
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;
    next();
  } catch (error) {
    res.status(403).json({ error: "Invalid or expired token" });
  }
}

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

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Stateful vs Stateless Authentication

This is the fundamental architectural difference:

Stateful (Sessions)

The server remembers who you are. It stores session data and looks it up on every request.

Client:  "Here's my session ID: abc123"
Server:  "Let me check my records... abc123... found it!
          You're Pratham, developer. Here's your data."
Enter fullscreen mode Exit fullscreen mode

Stateless (JWT)

The server doesn't remember anything. The token itself contains everything the server needs.

Client:  "Here's my JWT: eyJhbGci..."
Server:  "Let me verify the signature... valid!
          The token says you're Pratham, developer. Here's your data."
          (I never stored anything about you.)
Enter fullscreen mode Exit fullscreen mode
STATEFUL (Session):
┌────────┐     session ID      ┌────────┐
│ Client │ ──────────────────→ │ Server │ ──→ Session Store
│        │ ←────────────────── │        │ ←── (lookup required)
└────────┘     user data       └────────┘

STATELESS (JWT):
┌────────┐     JWT token       ┌────────┐
│ Client │ ──────────────────→ │ Server │     No store needed!
│        │ ←────────────────── │        │     (verify + decode)
└────────┘     user data       └────────┘
Enter fullscreen mode Exit fullscreen mode

Session vs JWT — Comparison Table

Feature Sessions JWT
Data stored on Server (memory, DB, Redis) Client (token contains the data)
Server state Stateful — server remembers Stateless — server forgets
Scalability Harder — session store shared across servers Easier — any server can verify the token
Performance DB/store lookup on every request Verify signature (no DB lookup)
Revocation Easy — delete session from store Hard — token valid until expiry
Storage Server-side (memory/DB/Redis) Client-side (cookie/localStorage)
Size Small (just a session ID in cookie) Larger (full payload in token)
Best for Traditional web apps, server-rendered APIs, mobile apps, microservices
Multiple servers Need shared session store Any server with the secret can verify
Logout Destroy session — instant Can't truly invalidate until expiry

When to Use Each Method

Use Sessions When:

  • You're building a traditional server-rendered web app (like with EJS or Pug templates)
  • You need easy logout — destroy the session and the user is immediately logged out
  • You want server-side control over active sessions (see who's logged in, force logout)
  • You're running on a single server or have a shared session store (Redis)
  • Security is the top priority — session IDs reveal nothing; data stays on the server

Use JWT When:

  • You're building a REST API consumed by a React frontend, mobile app, or third-party service
  • You need to scale across multiple servers without sharing state
  • You're building microservices where different services need to verify authentication independently
  • You want stateless architecture — no session store to manage
  • You're building a mobile app where cookies aren't native

The Decision Flowchart

"What am I building?"
     │
     ├── Server-rendered web app (EJS, templates)?
     │     → Use SESSIONS (with cookies)
     │
     ├── REST API + React/mobile frontend?
     │     → Use JWT (in Authorization header)
     │
     ├── Microservices architecture?
     │     → Use JWT (stateless, no shared store)
     │
     └── Need instant logout / session management?
           → Use SESSIONS (easy revocation)
Enter fullscreen mode Exit fullscreen mode

How They Work Together

In practice, these approaches often combine:

JWT in a Cookie

You can store a JWT inside a cookie, getting the benefits of both: the self-contained nature of JWT and the automatic sending of cookies.

// Login — store JWT in an httpOnly cookie
app.post("/login", (req, res) => {
  const token = jwt.sign({ id: 1, name: "Pratham" }, SECRET, {
    expiresIn: "24h",
  });

  res.cookie("token", token, {
    httpOnly: true,
    secure: true,
    sameSite: "strict",
    maxAge: 24 * 60 * 60 * 1000,
  });

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

// Protected route — read JWT from cookie
app.get("/api/profile", (req, res) => {
  const token = req.cookies.token;
  if (!token) return res.status(401).json({ error: "Not authenticated" });

  try {
    const user = jwt.verify(token, SECRET);
    res.json({ user });
  } catch (error) {
    res.status(403).json({ error: "Invalid token" });
  }
});
Enter fullscreen mode Exit fullscreen mode

JWT in Authorization Header (API-style)

// Client sends:
fetch("/api/profile", {
  headers: {
    Authorization: `Bearer ${token}`,
  },
});

// Server reads:
const token = req.headers.authorization.split(" ")[1];
Enter fullscreen mode Exit fullscreen mode

Cookie vs Header — When to Use Which

Approach Best For Pros
JWT in Cookie Web apps (same domain) Automatic, httpOnly (more secure)
JWT in Auth Header APIs, mobile apps, cross-domain Flexible, works everywhere

Common Misconceptions

❌ "JWTs are more secure than sessions"

Not necessarily. JWTs can't be revoked easily. If a JWT is stolen, it's valid until it expires. Sessions can be immediately destroyed.

❌ "Cookies are insecure"

Cookies with httpOnly, secure, and sameSite flags are quite secure. They're safer than storing tokens in localStorage because JavaScript can't access them (protecting against XSS attacks).

❌ "You have to choose one or the other"

You can absolutely use JWTs stored in cookies. Many production apps do this — combining the stateless nature of JWT with the automatic and secure transport of cookies.

❌ "Sessions are outdated"

Sessions are still widely used in production. They're excellent for server-rendered apps and scenarios where you need instant session revocation.


Let's Practice: Hands-On Assignment

Part 1: Session-Based Auth

const express = require("express");
const session = require("express-session");

const app = express();
app.use(express.json());
app.use(session({ secret: "secret", resave: false, saveUninitialized: false }));

const users = [{ id: 1, email: "pratham@dev.in", password: "pass123", name: "Pratham" }];

app.post("/login", (req, res) => {
  const user = users.find(
    (u) => u.email === req.body.email && u.password === req.body.password,
  );
  if (!user) return res.status(401).json({ error: "Invalid credentials" });

  req.session.user = { id: user.id, name: user.name };
  res.json({ message: `Welcome, ${user.name}!` });
});

app.get("/profile", (req, res) => {
  if (!req.session.user) return res.status(401).json({ error: "Not logged in" });
  res.json(req.session.user);
});

app.post("/logout", (req, res) => {
  req.session.destroy();
  res.json({ message: "Logged out" });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Part 2: JWT-Based Auth

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

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

const SECRET = "jwt-secret";

app.post("/login", (req, res) => {
  // Same user validation...
  const token = jwt.sign({ id: 1, name: "Pratham" }, SECRET, { expiresIn: "1h" });
  res.json({ token });
});

app.get("/profile", (req, res) => {
  const auth = req.headers.authorization;
  if (!auth) return res.status(401).json({ error: "No token" });

  try {
    const user = jwt.verify(auth.split(" ")[1], SECRET);
    res.json(user);
  } catch (e) {
    res.status(403).json({ error: "Invalid token" });
  }
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Part 3: Compare Both

Build both versions for the same app and test:

  • Login with both approaches
  • Access a protected route
  • Try accessing without auth
  • Log out (session: destroy session; JWT: delete token on client)
  • Notice: after "logout," the JWT is still valid if you save it somewhere. The session is truly gone.

Key Takeaways

  1. Cookies are a transport mechanism — they carry data (session IDs or JWTs) between client and server automatically. They are not an authentication method by themselves.
  2. Sessions are stateful — the server stores user data and looks it up via a session ID. Easy to revoke, harder to scale across multiple servers.
  3. JWTs are stateless — the token contains the user data. Easy to scale, harder to revoke before expiry.
  4. Use sessions for server-rendered apps, when you need instant logout, or when running on a single server. Use JWTs for REST APIs, mobile apps, microservices, and cross-server authentication.
  5. They're not mutually exclusive — JWT in a cookie is a common, practical pattern that combines the benefits of both approaches.

Wrapping Up

Sessions, cookies, and JWTs aren't competing technologies — they're different tools that sometimes work together. Cookies carry things. Sessions remember things on the server. JWTs carry things that prove who you are without the server needing to remember. Which one you use depends on what you're building, how you're scaling, and what kind of control you need over user sessions.

I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. Authentication was the topic where theory and practice collided — understanding why you'd choose sessions over JWT (or vice versa) is just as important as knowing how to implement them.

Connect with me on LinkedIn or visit PrathamDEV.in. More articles on the way.

Happy coding! 🚀


Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode

Top comments (0)