Node.js Interview Prep
Authentication and Security

Session-Based Authentication

Cookies, Stores, and Stateful Auth

LinkedIn Hook

"Your users log in once. How does the server remember them on the next request?"

HTTP is stateless. Every request arrives like a stranger knocking on the door. Yet somehow, after a user logs in, your app knows who they are on every page they visit. That magic has a name — sessions.

Most developers reach for JWTs without thinking. But for traditional web apps, server-rendered dashboards, and anything that needs instant logout, session-based authentication is still the gold standard. It is simpler, more secure by default, and gives you something JWTs cannot — the ability to revoke access in a single database call.

The catch? You need a session store. The default MemoryStore in express-session is a one-way ticket to production disaster — it leaks memory, dies on restart, and breaks across multiple instances. Real apps use Redis or Postgres to hold session state.

In Lesson 8.2, I break down how sessions work end-to-end — cookies, stores, CSRF protection, and exactly when to pick sessions over JWTs.

Read the full lesson -> [link]

#NodeJS #Authentication #Sessions #WebSecurity #BackendDevelopment #InterviewPrep


Session-Based Authentication thumbnail


What You'll Learn

  • How sessions actually work — session ID in a cookie, data on the server
  • Why express-session is the standard middleware and how to configure it
  • Why MemoryStore is dangerous in production and which stores to use instead
  • The four cookie flags every session must set: httpOnly, secure, sameSite, maxAge
  • How sessions compare to JWTs and when to choose each
  • How to protect session-based apps from CSRF attacks
  • How to log users out properly by destroying session state

The Coat Check Analogy — How Sessions Really Work

Imagine you walk into a fancy restaurant in winter. At the entrance, you hand your heavy coat to the coat check attendant. In return, they give you a small numbered ticket — say, ticket #427. You put the ticket in your pocket and go enjoy dinner.

When you leave hours later, you hand back ticket #427. The attendant looks at the rack, finds the coat hanging at slot 427, and returns it to you. The ticket itself is meaningless — a tiny scrap of paper with a number. The real value lives on the rack behind the counter.

That is exactly how session-based authentication works. When you log in, the server creates a session record on its side (your "coat") — containing your user ID, role, preferences, login time, whatever it needs. Then it generates a random opaque session ID (your "ticket") and sends it back as a cookie. On every subsequent request, your browser hands back the cookie, the server looks up the matching session record, and instantly knows who you are.

The key insight: the cookie holds nothing sensitive. It is just a random string. All the actual user data lives on the server, which means the server has total control. Want to log someone out? Delete their session record. Want to invalidate every session at once after a security breach? Drop the entire session table. You cannot do that with JWTs.

+---------------------------------------------------------------+
|           SESSION-BASED AUTH FLOW                             |
+---------------------------------------------------------------+
|                                                                |
|  1. LOGIN REQUEST                                              |
|     Browser ----[POST /login {email, password}]----> Server    |
|                                                                |
|  2. SERVER VERIFIES CREDENTIALS                                |
|     - Looks up user in DB                                      |
|     - Compares bcrypt hash                                     |
|     - Creates session record in store (Redis/Postgres):        |
|         sess:a8f2c1... -> { userId: 42, role: 'admin' }        |
|                                                                |
|  3. SERVER SENDS COOKIE                                        |
|     Server ----[Set-Cookie: connect.sid=a8f2c1...]---> Browser |
|     Flags: HttpOnly; Secure; SameSite=Lax; Path=/              |
|                                                                |
|  4. NEXT REQUEST (and every one after)                         |
|     Browser ----[Cookie: connect.sid=a8f2c1...]-----> Server   |
|                                                                |
|  5. SERVER LOOKS UP SESSION                                    |
|     - Reads cookie -> session ID                               |
|     - Fetches sess:a8f2c1... from Redis                        |
|     - Attaches req.session = { userId: 42, role: 'admin' }     |
|     - Route handler runs with user context                     |
|                                                                |
|  6. LOGOUT                                                     |
|     Browser ----[POST /logout]----------------------> Server   |
|     - req.session.destroy() -> deletes record from Redis       |
|     - Server clears cookie via Set-Cookie header               |
|                                                                |
+---------------------------------------------------------------+

Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). Center: a numbered timeline showing six steps of a session login flow. Each step has a small browser icon on the left and a server icon on the right with arrows between them. Step 3 highlights a cookie labeled 'connect.sid=a8f2...' in amber (#ffb020). Step 5 shows a Redis store icon in green (#68a063) with the lookup. White monospace labels for each step. Subtle grid overlay."


express-session — The Standard Middleware

express-session is the official Express middleware for managing sessions. It handles cookie parsing, session ID generation, store integration, and attaching req.session to every request. You configure it once and it does the rest.

Installing the Pieces

# Core middleware
npm install express-session

# Production session store backed by Redis
npm install connect-redis redis

# Or backed by Postgres
npm install connect-pg-simple pg

Basic Configuration with Redis

// app.js
// Wire up express-session with a Redis-backed store for production use
const express = require('express');
const session = require('express-session');
const { RedisStore } = require('connect-redis');
const { createClient } = require('redis');

const app = express();

// Create and connect a Redis client up front
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);

// Build the store instance that express-session will use
const store = new RedisStore({
  client: redisClient,
  prefix: 'sess:', // Optional namespace inside Redis
});

app.use(
  session({
    store, // Without this, MemoryStore is used (BAD for production)
    secret: process.env.SESSION_SECRET, // Used to sign the session ID cookie
    name: 'sid', // Custom cookie name (avoid revealing your stack)
    resave: false, // Do not save session if it was not modified
    saveUninitialized: false, // Do not create session until something is stored
    rolling: true, // Reset cookie expiry on every response
    cookie: {
      httpOnly: true, // JavaScript cannot read this cookie (XSS defense)
      secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
      sameSite: 'lax', // CSRF defense — see CSRF section below
      maxAge: 1000 * 60 * 60 * 24, // 24 hours in milliseconds
    },
  })
);

module.exports = app;

What each option does:

  • secret — signs the session ID cookie so attackers cannot forge one. Keep this in an environment variable, never in code.
  • resave: false — avoids re-saving an unchanged session on every request. Saves Redis writes.
  • saveUninitialized: false — avoids creating empty session records for anonymous visitors. Saves storage and is required by GDPR for cookie consent.
  • rolling: true — slides the expiration forward on every request. Active users stay logged in; idle users get logged out.

Why MemoryStore Is Dangerous in Production

If you forget to pass a store option, express-session falls back to its built-in MemoryStore. The npm package itself prints a warning at startup, and for good reason.

+---------------------------------------------------------------+
|           MEMORYSTORE: WHY IT BREAKS PRODUCTION               |
+---------------------------------------------------------------+
|                                                                |
|  PROBLEM 1: MEMORY LEAK                                        |
|  Sessions never expire from memory automatically.              |
|  After a few days under load, your Node process dies of OOM.   |
|                                                                |
|  PROBLEM 2: LOST ON RESTART                                    |
|  Deploy a new version? Every user is logged out.               |
|  Process crashes? Every user is logged out.                    |
|                                                                |
|  PROBLEM 3: NO MULTI-INSTANCE SUPPORT                          |
|  You scale to 3 Node instances behind a load balancer.         |
|  User logs in via instance A. Next request hits instance B.    |
|  Instance B has no record of the session -> logged out again.  |
|                                                                |
|  PROBLEM 4: NO SHARED STATE                                    |
|  Cannot list a user's active sessions across devices.          |
|  Cannot force-logout a user from an admin dashboard.           |
|                                                                |
|  SOLUTION:                                                     |
|  Use connect-redis or connect-pg-simple. Always.               |
|                                                                |
+---------------------------------------------------------------+

Postgres Alternative with connect-pg-simple

If you already run Postgres and do not want to add Redis to your stack, connect-pg-simple stores sessions in a regular table.

// pg-session.js
// Use Postgres as the session store when Redis is not available
const session = require('express-session');
const pgSession = require('connect-pg-simple')(session);
const { Pool } = require('pg');

const pgPool = new Pool({ connectionString: process.env.DATABASE_URL });

const store = new pgSession({
  pool: pgPool,
  tableName: 'user_sessions', // Table is auto-created on first run
  createTableIfMissing: true,
  pruneSessionInterval: 60, // Cleanup expired sessions every 60 seconds
});

module.exports = store;

Redis is faster (in-memory, dedicated to this workload) but Postgres works fine for most apps and avoids running an extra service.


Login Handler — Setting the Session

Once express-session is wired up, logging a user in is just assigning fields to req.session. The middleware handles serializing them to the store and sending the cookie automatically.

// routes/auth.js
// Login route — verify password, then attach user data to req.session
const express = require('express');
const bcrypt = require('bcrypt');
const router = express.Router();
const { findUserByEmail } = require('../db/users');

router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  // 1. Look up the user
  const user = await findUserByEmail(email);
  if (!user) {
    // Use a generic message — do not reveal whether email exists
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // 2. Verify password against bcrypt hash
  const valid = await bcrypt.compare(password, user.passwordHash);
  if (!valid) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // 3. CRITICAL: regenerate the session ID after login
  // This prevents session fixation attacks where an attacker
  // sets a known session ID before the victim logs in
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: 'Session error' });

    // 4. Store user data on the session
    req.session.userId = user.id;
    req.session.role = user.role;
    req.session.loginAt = Date.now();

    // 5. Save explicitly so the response only sends after persistence
    req.session.save((err) => {
      if (err) return res.status(500).json({ error: 'Session error' });
      res.json({ ok: true, userId: user.id });
    });
  });
});

module.exports = router;

The regenerate step is easy to forget but essential. Without it, an attacker who tricks a victim into using a pre-set session ID can hijack the account after login.


Protecting Routes — The requireAuth Middleware

Once the session holds a userId, every protected route just checks for it. Wrap that check in middleware so you write it once.

// middleware/requireAuth.js
// Reject any request that does not have a valid logged-in session
function requireAuth(req, res, next) {
  // express-session attaches req.session on every request
  // It exists even for anonymous users, but userId only exists post-login
  if (!req.session || !req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  next();
}

// Optional: role-based check builds on top of requireAuth
function requireRole(role) {
  return (req, res, next) => {
    if (!req.session?.userId) {
      return res.status(401).json({ error: 'Not authenticated' });
    }
    if (req.session.role !== role) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

module.exports = { requireAuth, requireRole };
// routes/dashboard.js
// Apply the middleware to any route that requires a logged-in user
const express = require('express');
const { requireAuth, requireRole } = require('../middleware/requireAuth');
const router = express.Router();

// Any logged-in user can see their dashboard
router.get('/dashboard', requireAuth, (req, res) => {
  res.json({ userId: req.session.userId, role: req.session.role });
});

// Only admins can access this route
router.delete('/users/:id', requireRole('admin'), async (req, res) => {
  // Delete logic here
  res.json({ ok: true });
});

module.exports = router;

Logout — Destroying the Session

Logout is the part developers most often get wrong. Simply clearing the cookie on the client is not enough — the session record still lives in Redis, and anyone who replays the old cookie can reuse it. You must destroy the server-side record.

// routes/auth.js (continued)
// Properly log a user out by deleting the session on the server
router.post('/logout', (req, res) => {
  // Capture the cookie name BEFORE destroying the session
  const cookieName = req.app.get('session cookie name') || 'sid';

  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: 'Logout failed' });
    }

    // Clear the cookie from the browser
    // Match the same options used when setting it
    res.clearCookie(cookieName, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      path: '/',
    });

    res.json({ ok: true });
  });
});

This pattern gives you the headline benefit of sessions: instant, server-side revocation. A single Redis DEL ends the session everywhere, on every device, forever. JWTs cannot do this without a separate revocation list — which defeats their main selling point of being stateless.


CSRF Protection with Sessions

Cookies have one ugly behavior: browsers send them automatically with every request to your domain — even requests triggered by a malicious site. That is the root of Cross-Site Request Forgery (CSRF). If bank.com uses session cookies and you visit evil.com, a hidden form on evil.com can POST to bank.com/transfer and your browser will helpfully attach your session cookie.

There are two standard defenses. The modern, simple one is sameSite=lax on your cookie — which we already set above. Browsers will refuse to send the cookie on cross-site POST requests. For older browser support, or when you cannot trust SameSite alone, use a CSRF token.

// middleware/csrf.js
// Double-submit cookie pattern — no server-side token storage needed
const crypto = require('crypto');

function issueCsrfToken(req, res, next) {
  // Generate a token if the user does not already have one
  if (!req.session.csrfToken) {
    req.session.csrfToken = crypto.randomBytes(32).toString('hex');
  }

  // Expose it via a NON-httpOnly cookie so client JS can read it
  // The session-stored copy is the source of truth
  res.cookie('csrf-token', req.session.csrfToken, {
    httpOnly: false, // Must be readable by JS to send in header
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
  });

  next();
}

function verifyCsrfToken(req, res, next) {
  // Skip safe methods that do not change state
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }

  // Token must come from a custom header (cannot be forged cross-site)
  const headerToken = req.get('X-CSRF-Token');
  const sessionToken = req.session.csrfToken;

  if (!headerToken || !sessionToken || headerToken !== sessionToken) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }

  next();
}

module.exports = { issueCsrfToken, verifyCsrfToken };
// app.js (wiring it in)
const { issueCsrfToken, verifyCsrfToken } = require('./middleware/csrf');

// Issue tokens after session middleware
app.use(issueCsrfToken);

// Verify on all state-changing routes
app.use('/api', verifyCsrfToken);

The frontend reads csrf-token from the cookie and sends it back in the X-CSRF-Token header on every POST/PUT/DELETE. An attacker on evil.com can trigger the request, but they cannot read the cookie (different origin), so they cannot set the matching header — and the request is rejected.

Note: the popular csurf package was deprecated in 2022 due to maintenance issues. The double-submit pattern above is the recommended replacement. If you need a maintained library, look at csrf-csrf.


Sessions vs JWT — When to Choose Which

This is the most common interview question in this lesson, so know it cold.

AspectSession-BasedJWT (Stateless)
Where state livesServer (Redis/DB)Inside the token
Storage requiredYes (session store)None
RevocationInstant (session.destroy)Hard (need denylist)
LogoutReal (server-side)Fake (client deletes token)
Token sizeTiny (32-byte ID)Large (header + payload + sig)
ScalingNeeds shared storeTrivially horizontal
Best forWeb apps, dashboardsMobile, microservices, APIs
CSRF riskYes (cookie-based)No (if using Authorization header)
XSS riskLow (httpOnly cookie)High (token in localStorage)
Cross-domainHard (cookie limits)Easy
Server restartNo effect (store is external)No effect
Audit/monitoringEasy (query session table)Hard (no central record)

Pick sessions when:

  • You are building a traditional web app served from one domain
  • You need real, instant logout (banking, healthcare, admin tools)
  • You need to list and manage active sessions per user
  • You can run Redis or share a database

Pick JWTs when:

  • You are building a mobile app or SPA hitting cross-domain APIs
  • You have microservices that need to verify identity without a central lookup
  • Short-lived access tokens with refresh tokens fit your model
  • You can tolerate the revocation problem (or solve it with short expiry)

A common production pattern is both: JWTs for service-to-service calls inside your backend, sessions for the user-facing web app. They are not enemies.


Common Mistakes

1. Using MemoryStore in production. Every Node.js tutorial shows express-session with no store option, which silently uses MemoryStore. It leaks memory, dies on restart, and breaks across instances. Always pass a real store (connect-redis or connect-pg-simple) and confirm the startup warning is gone.

2. Forgetting httpOnly on the session cookie. Without httpOnly, any XSS vulnerability lets attacker JavaScript read document.cookie and steal the session ID. With httpOnly, the cookie is invisible to scripts and only the browser can attach it to requests. This single flag blocks the most common session theft vector.

3. Skipping req.session.regenerate() after login. If you do not regenerate the session ID when authentication state changes, you are vulnerable to session fixation. An attacker pre-sets a session ID, tricks the victim into using it, then reuses it after the victim logs in. Always regenerate on login and on privilege change.

4. Logging out by clearing the cookie only. res.clearCookie('sid') tells the browser to drop its copy, but the session record in Redis still exists. If the cookie was leaked anywhere — browser history, proxy logs, an old backup — it still works. Always call req.session.destroy() to delete the server-side record.

5. Setting saveUninitialized: true. This creates an empty session record for every visitor — including bots, crawlers, and one-page bounces. Your session store fills with garbage, you waste storage, and you may violate GDPR cookie consent rules because you set a tracking cookie before the user did anything.


Interview Questions

1. "Walk me through what happens from the moment a user clicks Login to the moment they see their dashboard, in a session-based setup."

The browser sends a POST to /login with the email and password. The server looks up the user, compares the password against the bcrypt hash, and on success calls req.session.regenerate() to defeat session fixation. It then assigns req.session.userId = user.id and saves the session — express-session serializes that data to Redis under a key like sess:a8f2c1... and sets a Set-Cookie: sid=a8f2c1...; HttpOnly; Secure; SameSite=Lax header on the response. The browser stores the cookie and follows the redirect to /dashboard. On that next request, the browser automatically attaches the cookie. The session middleware reads the cookie, fetches sess:a8f2c1... from Redis, and attaches the deserialized data to req.session. The dashboard route handler sees req.session.userId, queries the database, and renders the page.

2. "Why is MemoryStore unsuitable for production?"

Four reasons. First, it leaks memory — sessions accumulate without expiration and eventually OOM the Node process. Second, it loses all sessions on restart, so every deploy logs every user out. Third, it does not work with multiple Node instances behind a load balancer because each instance has its own isolated memory — a request to instance A finds no session set on instance B. Fourth, it gives you no way to inspect, audit, or revoke sessions across the fleet. Production setups must use a shared external store like Redis (connect-redis) or Postgres (connect-pg-simple).

3. "What are the four important cookie flags for a session cookie and what does each one do?"

httpOnly: true blocks JavaScript from reading the cookie via document.cookie, which neutralizes session theft from XSS. secure: true ensures the browser only sends the cookie over HTTPS, preventing man-in-the-middle interception on public networks. sameSite: 'lax' (or 'strict') tells the browser not to attach the cookie on cross-site requests, which blocks the bulk of CSRF attacks. maxAge (or expires) sets the cookie lifetime — without it, the cookie becomes a session cookie that vanishes when the browser closes, which can be confusing for users who expect to stay logged in.

4. "How would you implement a real, instant logout — and why is this hard with JWTs?"

With sessions, logout is a single req.session.destroy() call, which removes the record from Redis. The next request from anyone using that cookie finds no matching session and is rejected. It is instant, atomic, and works across every device the user is logged in on if you destroy all their sessions. With JWTs, the token is self-contained — the server has no record of having issued it, so there is no record to delete. The token remains cryptographically valid until its exp claim. To force-logout a JWT user you must maintain a server-side denylist of revoked tokens and check it on every request, which reintroduces the exact stateful lookup that JWTs were supposed to avoid. The common compromise is short access tokens (5-15 minutes) plus refresh tokens, accepting that revocation has up to one access-token lifetime of latency.

5. "What is session fixation and how do you prevent it?"

Session fixation is an attack where the attacker forces a victim to use a session ID the attacker already knows. For example, the attacker visits the site, gets a session cookie, then tricks the victim (via a link with a session parameter, or by setting the cookie via XSS on a sibling subdomain) into adopting that same session ID. When the victim later logs in, the attacker — who still holds the same session ID — is now authenticated as the victim. The defense is one line: call req.session.regenerate() immediately after authenticating the user. This issues a fresh, random session ID and migrates the session data to it. The old ID the attacker knows becomes invalid. You should also regenerate when privileges change (e.g., admin elevation).


Quick Reference — Session Auth Cheat Sheet

+---------------------------------------------------------------+
|           EXPRESS-SESSION CHEAT SHEET                         |
+---------------------------------------------------------------+
|                                                                |
|  SETUP:                                                        |
|  const session = require('express-session')                    |
|  const { RedisStore } = require('connect-redis')               |
|  app.use(session({ store, secret, cookie: {...} }))            |
|                                                                |
|  COOKIE FLAGS (always set all four):                           |
|  httpOnly: true        -> blocks XSS theft                     |
|  secure: true          -> HTTPS only                           |
|  sameSite: 'lax'       -> blocks CSRF                          |
|  maxAge: 86400000      -> 24 hours                             |
|                                                                |
|  LOGIN:                                                        |
|  req.session.regenerate(() => {                                |
|    req.session.userId = user.id                                |
|    req.session.save(() => res.json({ ok: true }))              |
|  })                                                            |
|                                                                |
|  PROTECT ROUTE:                                                |
|  if (!req.session?.userId) return res.status(401)...           |
|                                                                |
|  LOGOUT:                                                       |
|  req.session.destroy(() => res.clearCookie('sid'))             |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           STORE CHOICES                                        |
+---------------------------------------------------------------+
|                                                                |
|  MemoryStore        -> DEV ONLY. Never in production.          |
|  connect-redis      -> Best choice. Fast, simple, scales.      |
|  connect-pg-simple  -> Good if you already run Postgres.       |
|  connect-mongo      -> Fine if Mongo is your primary DB.       |
|  connect-memcached  -> Legacy. Prefer Redis.                   |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           KEY RULES                                            |
+---------------------------------------------------------------+
|                                                                |
|  1. Never use MemoryStore in production                        |
|  2. Always set httpOnly + secure + sameSite + maxAge           |
|  3. Always regenerate session ID on login                      |
|  4. Always destroy session on logout (not just clear cookie)   |
|  5. Use saveUninitialized: false (GDPR + storage)              |
|  6. Store SESSION_SECRET in env vars, never in code            |
|  7. Add CSRF tokens for forms even with sameSite=lax           |
|  8. Sessions for web apps, JWTs for cross-domain APIs          |
|                                                                |
+---------------------------------------------------------------+

Prev: Lesson 8.1 -- JWT Authentication Next: Lesson 8.3 -- OAuth & Social Login


This is Lesson 8.2 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.

On this page