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
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)
});
| 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! │
│ ←────────────────────────────── │ │
│ │ │
└────┴─────┘ └──────────────────┘
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);
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 ──────┘
| 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 │
│ ←────────────────────────────── │ │
│ │ │
└────┴─────┘ └──────────────────┘
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);
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."
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.)
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 └────────┘
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)
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" });
}
});
JWT in Authorization Header (API-style)
// Client sends:
fetch("/api/profile", {
headers: {
Authorization: `Bearer ${token}`,
},
});
// Server reads:
const token = req.headers.authorization.split(" ")[1];
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);
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);
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
- 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.
- 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.
- JWTs are stateless — the token contains the user data. Easy to scale, harder to revoke before expiry.
- 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.
- 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)