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

  1. User submits credentials (email + password).
  2. Server verifies credentials, creates a session object in a session store (memory, Redis, MongoDB).
  3. Server sends a session ID in a cookie (Set-Cookie header).
  4. Browser automatically sends the cookie on every subsequent request.
  5. Server looks up the session ID in the store, retrieves user data.
  6. 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

StoreProsConsUse Case
Memory (default)Zero setupData lost on restart; leaks memory; single-process onlyDevelopment only
Redis (connect-redis)Extremely fast; TTL built in; shared across serversExtra infrastructureProduction (recommended)
MongoDB (connect-mongo)No extra service if already using MongoSlower than Redis for lookupsSmall-to-medium apps
PostgreSQL (connect-pg-simple)No extra service if already using PGSlower for session-heavy appsPG-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

  1. User submits credentials.
  2. Server verifies credentials, creates a signed token (JWT).
  3. Server sends the token in the response body (or sets it as a cookie).
  4. Client stores the token (cookie, memory, or localStorage).
  5. Client sends the token in the Authorization header on each request.
  6. Server verifies the signature and decodes the payload -- no database lookup needed.
  7. 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

AspectSession-BasedToken-Based (JWT)
StateStateful -- server stores sessionStateless -- token carries all data
Storage (server)Session store (Redis, DB)Nothing (just the secret key)
Storage (client)Cookie (automatic)Cookie, localStorage, or memory
Sent howCookie (automatic by browser)Authorization: Bearer <token> (manual)
ScalabilityHarder -- session store must be shared across serversEasier -- any server can verify with the secret
InvalidationEasy -- delete session from storeHard -- token valid until expiry (unless blacklist)
LogoutServer destroys sessionClient deletes token; server cannot force
SizeCookie: ~50 bytes (session ID only)JWT: 500+ bytes (payload + signature)
CSRF riskYes (cookies sent automatically)No (if using Authorization header)
XSS riskLower (httpOnly cookie)Higher (if stored in localStorage)
Mobile supportPossible but awkward (cookies)Natural (tokens in headers)
Cross-domainDifficult (cookie domain restrictions)Easy (token in any header)
Best forServer-rendered apps, single-domainSPAs, 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:

AttributeValuePurpose
httpOnlytruePrevents JavaScript from reading the cookie (XSS defense)
securetrueCookie only sent over HTTPS
sameSite'strict' or 'lax'Prevents CSRF (cookie not sent on cross-site requests)
maxAgemillisecondsCookie 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:

ValueBehavior
'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

  1. Sessions are stateful (server stores data); tokens are stateless (client carries data).
  2. Sessions use cookies (automatic); tokens use Authorization headers (manual) or cookies.
  3. Session invalidation is simple (delete from store); token invalidation requires a blacklist.
  4. For SPAs and mobile, tokens are more natural. For server-rendered apps, sessions are simpler.
  5. Hybrid: Store JWT in an httpOnly cookie for the security benefits of both approaches.
  6. Always set httpOnly, secure, and sameSite on auth cookies.

Explain-It Challenge

Explain without notes:

  1. You have 10 servers behind a load balancer. Why does session-based auth need shared storage (like Redis), while JWT-based auth does not?
  2. A user's account is compromised. With sessions, you can log them out instantly. How would you achieve the same with JWTs?
  3. Why is storing a JWT in localStorage riskier than in an httpOnly cookie?

Navigation: <- 3.14.b Password Security with Bcrypt | 3.14.d -- JWT Authentication ->