Episode 3 — NodeJS MongoDB Backend Architecture / 3.14 — Authentication and Authorization
3.14.c — Session vs Token Authentication
In one sentence: Session-based auth stores state on the server (session ID in a cookie); token-based auth stores state in a signed token the client carries -- each has trade-offs around scalability, security, and simplicity.
Navigation: <- 3.14.b Password Security with Bcrypt | 3.14.d -- JWT Authentication ->
1. Two fundamentally different approaches
SESSION-BASED TOKEN-BASED
───────────── ───────────
Client Server Client Server
│ login ──────►│ │ login ──────►│
│ │ create session │ │ create JWT
│ │ store in memory/DB │ │ (stateless)
│◄── cookie ────│ (session ID) │◄── token ─────│ (signed JWT)
│ │ │ │
│ request ────►│ │ request ────►│
│ cookie auto │ look up session │ Bearer token │ verify signature
│ │ in store │ │ decode payload
│◄── data ──────│ │◄── data ──────│
2. Session-based authentication
How it works
- User submits credentials (email + password).
- Server verifies credentials, creates a session object in a session store (memory, Redis, MongoDB).
- Server sends a session ID in a cookie (
Set-Cookieheader). - Browser automatically sends the cookie on every subsequent request.
- Server looks up the session ID in the store, retrieves user data.
- On logout, server destroys the session.
Setting up express-session
npm install express-session
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET, // signs the session ID cookie
resave: false, // don't save if not modified
saveUninitialized: false, // don't create empty sessions
cookie: {
httpOnly: true, // JS cannot read the cookie
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));
Login with sessions
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const isMatch = await user.comparePassword(password);
if (!isMatch) return res.status(401).json({ error: 'Invalid credentials' });
// Store user info in the session
req.session.userId = user._id;
req.session.role = user.role;
res.json({ message: 'Logged in', user: { id: user._id, name: user.name } });
});
Session middleware (protect routes)
function requireSession(req, res, next) {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
next();
}
app.get('/api/profile', requireSession, async (req, res) => {
const user = await User.findById(req.session.userId);
res.json({ user });
});
Logout with sessions
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.clearCookie('connect.sid'); // default cookie name
res.json({ message: 'Logged out' });
});
});
Session storage options
| Store | Pros | Cons | Use Case |
|---|---|---|---|
| Memory (default) | Zero setup | Data lost on restart; leaks memory; single-process only | Development only |
Redis (connect-redis) | Extremely fast; TTL built in; shared across servers | Extra infrastructure | Production (recommended) |
MongoDB (connect-mongo) | No extra service if already using Mongo | Slower than Redis for lookups | Small-to-medium apps |
PostgreSQL (connect-pg-simple) | No extra service if already using PG | Slower for session-heavy apps | PG-based stacks |
npm install connect-redis ioredis
const RedisStore = require('connect-redis').default;
const { Redis } = require('ioredis');
const redisClient = new Redis(process.env.REDIS_URL);
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, maxAge: 86400000 },
}));
3. Token-based authentication
How it works
- User submits credentials.
- Server verifies credentials, creates a signed token (JWT).
- Server sends the token in the response body (or sets it as a cookie).
- Client stores the token (cookie, memory, or localStorage).
- Client sends the token in the
Authorizationheader on each request. - Server verifies the signature and decodes the payload -- no database lookup needed.
- On logout, client deletes the token (server cannot force invalidation without extra work).
Basic token flow
const jwt = require('jsonwebtoken');
// --- Login: create token ---
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const isMatch = await user.comparePassword(password);
if (!isMatch) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign(
{ userId: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token });
});
// --- Protected route: verify token ---
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // { userId, role, iat, exp }
next();
} catch (err) {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
4. Session vs token -- detailed comparison
| Aspect | Session-Based | Token-Based (JWT) |
|---|---|---|
| State | Stateful -- server stores session | Stateless -- token carries all data |
| Storage (server) | Session store (Redis, DB) | Nothing (just the secret key) |
| Storage (client) | Cookie (automatic) | Cookie, localStorage, or memory |
| Sent how | Cookie (automatic by browser) | Authorization: Bearer <token> (manual) |
| Scalability | Harder -- session store must be shared across servers | Easier -- any server can verify with the secret |
| Invalidation | Easy -- delete session from store | Hard -- token valid until expiry (unless blacklist) |
| Logout | Server destroys session | Client deletes token; server cannot force |
| Size | Cookie: ~50 bytes (session ID only) | JWT: 500+ bytes (payload + signature) |
| CSRF risk | Yes (cookies sent automatically) | No (if using Authorization header) |
| XSS risk | Lower (httpOnly cookie) | Higher (if stored in localStorage) |
| Mobile support | Possible but awkward (cookies) | Natural (tokens in headers) |
| Cross-domain | Difficult (cookie domain restrictions) | Easy (token in any header) |
| Best for | Server-rendered apps, single-domain | SPAs, mobile apps, microservices, APIs |
5. When to use which
Use sessions when:
- Building a server-rendered app (EJS, Pug, etc.)
- Single server or simple deployment
- Need instant logout / session invalidation
- Users are on a single domain
- You want simpler implementation
Use tokens (JWT) when:
- Building a SPA (React, Vue, Angular) with a separate API
- Mobile apps need authentication
- Microservices architecture (multiple services verify the same token)
- Cross-domain or cross-origin requests
- Stateless scaling is a priority
6. Hybrid approaches
Many production apps combine both:
Pattern 1: JWT in HTTP-only cookie
// Server sets JWT as a cookie (not in response body)
router.post('/login', async (req, res) => {
// ... verify credentials ...
const token = jwt.sign({ userId: user._id, role: user.role }, SECRET, {
expiresIn: '24h',
});
res.cookie('token', token, {
httpOnly: true, // JavaScript cannot read it
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 86400000, // 24 hours
});
res.json({ message: 'Logged in', user });
});
// Middleware reads from cookie instead of Authorization header
function authMiddleware(req, res, next) {
const token = req.cookies.token;
if (!token) return res.status(401).json({ error: 'Not authenticated' });
try {
req.user = jwt.verify(token, SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
Best of both worlds: Stateless verification (JWT) + browser security (httpOnly cookie).
Pattern 2: Session + token blacklist
Use JWTs normally but maintain a blacklist of revoked tokens in Redis for instant invalidation.
// On logout, add token to blacklist
router.post('/logout', authMiddleware, async (req, res) => {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000); // remaining time
await redisClient.setex(`blacklist:${token}`, ttl, 'revoked');
res.json({ message: 'Logged out' });
});
// In auth middleware, check blacklist
function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
// Check blacklist first
const isBlacklisted = await redisClient.get(`blacklist:${token}`);
if (isBlacklisted) return res.status(401).json({ error: 'Token revoked' });
try {
req.user = jwt.verify(token, SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
7. Cookie security attributes
Whether using sessions or JWTs stored in cookies, these attributes are critical:
| Attribute | Value | Purpose |
|---|---|---|
httpOnly | true | Prevents JavaScript from reading the cookie (XSS defense) |
secure | true | Cookie only sent over HTTPS |
sameSite | 'strict' or 'lax' | Prevents CSRF (cookie not sent on cross-site requests) |
maxAge | milliseconds | Cookie lifetime; omit for session cookie (deleted on browser close) |
domain | '.example.com' | Which domain can receive the cookie |
path | '/' | Which URL path the cookie applies to |
res.cookie('token', value, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', // 'strict' blocks all cross-site; 'lax' allows top-level navigation
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
sameSite values explained:
| Value | Behavior |
|---|---|
'strict' | Cookie never sent on cross-site requests (even clicking a link from email) |
'lax' | Cookie sent on top-level navigation (GET links) but not on cross-site POST/fetch |
'none' | Cookie sent everywhere; requires secure: true (used for third-party cookies) |
8. Key takeaways
- Sessions are stateful (server stores data); tokens are stateless (client carries data).
- Sessions use cookies (automatic); tokens use Authorization headers (manual) or cookies.
- Session invalidation is simple (delete from store); token invalidation requires a blacklist.
- For SPAs and mobile, tokens are more natural. For server-rendered apps, sessions are simpler.
- Hybrid: Store JWT in an httpOnly cookie for the security benefits of both approaches.
- Always set
httpOnly,secure, andsameSiteon auth cookies.
Explain-It Challenge
Explain without notes:
- You have 10 servers behind a load balancer. Why does session-based auth need shared storage (like Redis), while JWT-based auth does not?
- A user's account is compromised. With sessions, you can log them out instantly. How would you achieve the same with JWTs?
- Why is storing a JWT in
localStorageriskier than in anhttpOnlycookie?
Navigation: <- 3.14.b Password Security with Bcrypt | 3.14.d -- JWT Authentication ->