Skip to main content

🔐 Express Sessions

In the last few lessons you handed the client a token and trusted it to carry its own proof of identity. Sessions flip that model: the server keeps the secret, and the browser holds nothing but an opaque ticket stub. In this lesson you'll wire up express-session, understand exactly what lives where, and log a user in and out safely.

Week 9 · Day 3 (Wednesday: Session-based Authentication) · Lecture 1

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain how a session ID cookie and a server-side store cooperate to authenticate a user
  • Install and configure express-session with safe resave and saveUninitialized settings
  • Load the session secret from an environment variable instead of hard-coding it
  • Read from and write to req.session across requests
  • Build login, logout, and "who am I" routes backed by a session
  • Regenerate the session ID on login to defeat session fixation, and destroy it on logout

Estimated Time: 70 minutes

Practice: Build a login/logout flow with an isAuthenticated guard and a session-fixation-proof login.

In This Lesson

What Is a Session?

HTTP has no memory. Each request arrives as a stranger — the server has no built-in way to know that the person asking for /dashboard is the same person who logged in a moment ago. A session is how we give the server that memory.

📖 The coat-check analogy

Walk into a theater and hand over your coat. The attendant hangs it on a numbered hook and gives you a small paper ticket with that number. The ticket carries no information about your coat — it's just a reference. Your coat (the real data) stays behind the counter; you carry only the ticket (the session ID). When you return, you present the ticket and the attendant looks up hook #42.

Server-side sessions work identically. Your user data lives in a session store on the server. The browser holds only a signed cookie containing the opaque session ID. Steal the cookie and you have a ticket; the coat is still guarded server-side.

This is the defining property of session-based auth: state lives on the server, and the client holds only an opaque identifier. Contrast that with a JWT, where the client carries the actual claims (user id, role, expiry) inside the token itself. We'll compare the two head-to-head later in the lesson.

How Session Auth Flows

Three parties collaborate on every authenticated request: the client (holding the cookie), the server (running your Express app), and the session store (where session data is persisted). Follow the ticket as it changes hands.

sequenceDiagram participant C as Client (browser) participant S as Express Server participant St as Session Store Note over C,St: Login C->>S: POST /login (email, password) S->>S: Verify credentials S->>St: Create session, save user id St-->>S: session ID S-->>C: Set-Cookie: connect.sid=opaque-id Note over C,St: Authenticated request C->>S: GET /profile (Cookie: connect.sid) S->>St: Look up session by ID St-->>S: session data (user id, role) S-->>C: 200 profile JSON Note over C,St: Logout C->>S: POST /logout S->>St: Destroy session S-->>C: Clear cookie

The key insight from this diagram: the cookie is looked up in the store on every request. That lookup is the session's superpower and its cost. The superpower is instant revocation — delete the row in the store and the user is logged out everywhere, immediately. The cost is that the store must be fast and shared across all your servers.

💡 The four moving parts

  • Session ID — a long, random, unguessable string
  • Session cookie — carries the ID (signed with your secret) on the client
  • Session store — server-side storage for the actual data
  • Session middlewareexpress-session, which glues the three together

Setting Up express-session

The middleware does all the heavy lifting: it reads the incoming cookie, loads the matching session, exposes it as req.session, and writes any changes back to the store before the response goes out.

Install

npm install express express-session
// dotenv keeps secrets out of your source code:
npm install dotenv

A minimal, safe configuration

// app.js
require('dotenv').config();
const express = require('express');
const session = require('express-session');

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

// Trust the first proxy so `secure` cookies work behind a load balancer/HTTPS terminator.
app.set('trust proxy', 1);

app.use(session({
  name: 'sid',                       // rename from the default 'connect.sid' to hide the stack
  secret: process.env.SESSION_SECRET, // signs the cookie — NEVER hard-code this
  resave: false,                     // don't re-save an unchanged session on every request
  saveUninitialized: false,          // don't create empty sessions for anonymous visitors
  cookie: {
    httpOnly: true,                                  // JavaScript can't read it (blocks XSS theft)
    secure: process.env.NODE_ENV === 'production',   // HTTPS-only in production
    sameSite: 'lax',                                 // sensible CSRF default
    maxAge: 1000 * 60 * 60                           // 1 hour, in milliseconds
  }
  // NOTE: no `store` here means the default MemoryStore — fine to learn on,
  // but replaced with Redis/Mongo before production (next lesson).
}));

app.get('/', (req, res) => {
  req.session.views = (req.session.views || 0) + 1;
  res.send(`You have visited this page ${req.session.views} time(s).`);
});

app.listen(3000, () => console.log('http://localhost:3000'));

Output — reload the page a few times

You have visited this page 1 time(s).
You have visited this page 2 time(s).
You have visited this page 3 time(s).

That counter survives across requests because express-session restored req.session from the store using the ID in your cookie. Notice you never touched the cookie directly — you just read and wrote plain properties on req.session.

⚠️ The secret is the whole ballgame

The secret signs the session cookie so a tampered ID is rejected. Generate a long, random one and store it in the environment, never in Git:

// Generate a strong secret once, then paste it into your .env file:
require('crypto').randomBytes(32).toString('hex');
// .env  ->  SESSION_SECRET=1f3c...e9a2  (64 hex chars)

If SESSION_SECRET leaks, an attacker can forge valid session cookies. Rotate it if that ever happens.

The Configuration Options

Two options trip up nearly everyone: resave and saveUninitialized. Setting both to false is the modern default, and understanding why makes the rest click.

OptionWhat it doesRecommended
secretSigns the session ID cookieStrong value from process.env
resaveForces save back to the store even if unchangedfalse — avoids needless writes & race conditions
saveUninitializedSaves brand-new, empty sessionsfalse — no session until you store something; also better for consent laws
nameCookie name (default connect.sid)Rename it, e.g. sid
storeWhere session data livesRedis or Mongo in production (default MemoryStore leaks)
rollingResets maxAge on every responsetrue for a sliding idle timeout
cookieFlags for the session cookiehttpOnly, secure, sameSite, maxAge

⚠️ Never ship the default MemoryStore

With no store, sessions live in a plain object in your Node process. It leaks memory under load, loses every session on restart, and isn't shared across multiple servers or workers. It literally prints a warning to your console in production. The very next lesson swaps it for connect-redis / connect-mongo.

Login, Logout & Guards

Now the real thing. After you verify a password, you write the user's identity into the session; on protected routes you read it back; on logout you destroy it.

The login route

const bcrypt = require('bcrypt');
const User = require('../models/User');

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

  const user = await User.findOne({ email });
  // Compare even the "no user" case to avoid leaking which emails exist (timing).
  const ok = user && await bcrypt.compare(password, user.passwordHash);
  if (!ok) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  // Regenerate FIRST to get a fresh session ID (defeats fixation — see below),
  // THEN store the user identity in the new session.
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ message: 'Login failed' });

    req.session.userId = user._id.toString();
    req.session.role = user.role;

    // Persist before responding so the client's next request finds it.
    req.session.save((err) => {
      if (err) return res.status(500).json({ message: 'Login failed' });
      res.json({ message: 'Logged in', user: { id: user._id, email: user.email } });
    });
  });
});

An authentication guard

// middleware/auth.js
function isAuthenticated(req, res, next) {
  if (req.session && req.session.userId) return next();
  return res.status(401).json({ message: 'Authentication required' });
}

function requireRole(role) {
  return (req, res, next) => {
    if (req.session?.role === role) return next();
    return res.status(403).json({ message: 'Forbidden' });
  };
}

module.exports = { isAuthenticated, requireRole };
// Use the guard on any protected route:
const { isAuthenticated, requireRole } = require('./middleware/auth');

router.get('/profile', isAuthenticated, async (req, res) => {
  const user = await User.findById(req.session.userId).select('-passwordHash');
  res.json({ user });
});

router.get('/admin', isAuthenticated, requireRole('admin'), (req, res) => {
  res.json({ message: 'Welcome, admin' });
});

The logout route

router.post('/logout', (req, res) => {
  req.session.destroy((err) => {           // remove the session from the store
    if (err) return res.status(500).json({ message: 'Logout failed' });
    res.clearCookie('sid');                // tell the browser to drop the cookie
    res.json({ message: 'Logged out' });
  });
});

✅ Why logout is genuinely "logged out"

Because the session lives server-side, req.session.destroy() deletes it from the store. Even if the old cookie is replayed, the lookup finds nothing and the user is anonymous again. This instant, server-side revocation is exactly what stateless JWTs make hard.

Regeneration & Session Fixation

A session fixation attack tricks a victim into using a session ID the attacker already knows. If the ID never changes when the victim logs in, the attacker's known ID becomes an authenticated one — and they're in.

sequenceDiagram participant A as Attacker participant V as Victim participant S as Server A->>S: Visit site, obtain session ID X A->>V: Trick victim into using ID X V->>S: Log in (session still ID X) Note over S: If ID is NOT regenerated, X is now authenticated A->>S: Reuse ID X — logged in as victim!

The fix is one line, and you already saw it above: call req.session.regenerate() at login. It issues a brand-new random ID and discards the old one, so any ID the attacker planted is now worthless.

// The pattern: regenerate, then write identity into the FRESH session.
req.session.regenerate((err) => {
  if (err) return next(err);
  req.session.userId = user._id.toString();
  req.session.save(next);
});

💡 Also regenerate on privilege changes

Beyond login, rotate the session ID whenever privileges change — for example when a user elevates to admin or completes step-up authentication. Same one-liner, same protection.

Sessions vs. JWT: Choosing Deliberately

You've now used both models in this module. Neither is "better" — they trade different things.

ConcernServer SessionsJWT
StateStateful — data in a server storeStateless — data inside the token
What the client holdsOnly an opaque session IDThe full signed claims
RevocationEasy — delete from the storeHard — needs a blocklist / short expiry
Horizontal scalingNeeds a shared store (Redis)Trivial — nothing shared
Best fitServer-rendered apps, one domain, tight controlAPIs, microservices, mobile, cross-domain
Rule of thumb: if you can run a shared session store and want instant revocation, sessions are simpler and safer. If you're fanning out across many independent services, JWTs avoid the shared bottleneck. Many real apps use both — sessions for the web front end, short-lived JWTs for the API.

Practice & Quiz

🏋️ Exercise 1: A visit counter that resets on logout

Goal: Build three routes: GET /count increments and returns req.session.count; POST /login sets req.session.userId after regenerating; POST /logout destroys the session. Prove the counter survives across requests but resets after logout.

💡 Hint

Initialize with req.session.count = (req.session.count || 0) + 1. On logout, req.session.destroy() wipes everything, so the next /count starts fresh at 1.

✅ Solution
app.get('/count', (req, res) => {
  req.session.count = (req.session.count || 0) + 1;
  res.json({ count: req.session.count });
});

app.post('/login', (req, res) => {
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ message: 'Login failed' });
    req.session.userId = 'demo-user';
    req.session.save(() => res.json({ message: 'Logged in' }));
  });
});

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

🏋️ Exercise 2: Harden the config

Goal: Take a starter config with secret: 'keyboard cat', saveUninitialized: true, and no cookie flags. Rewrite it to be production-safe.

✅ Solution
app.use(session({
  name: 'sid',
  secret: process.env.SESSION_SECRET,   // from the environment, strong & random
  resave: false,
  saveUninitialized: false,             // no session for anonymous visitors
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 1000 * 60 * 60
  }
}));

🎯 Quick Quiz

Question 1: With server-side sessions, what does the browser actually store?

Question 2: Why call req.session.regenerate() during login?

Question 3: Which is a genuine advantage of sessions over JWTs?

Best Practices & Pitfalls

✅ Do

  • Load secret from process.env — long, random, and never committed
  • Set resave: false and saveUninitialized: false
  • Set cookie flags httpOnly, secure (in prod), and sameSite
  • Call req.session.regenerate() on login and on privilege changes
  • req.session.destroy() and res.clearCookie() on logout
  • Swap the MemoryStore for a real store before production (next lesson)

❌ Don't

  • Hard-code the session secret or ship 'keyboard cat'
  • Store large or sensitive objects in the session — keep it to ids and flags
  • Leave saveUninitialized: true and create empty sessions for every bot
  • Forget app.set('trust proxy', 1) when behind HTTPS termination — secure cookies silently won't set

⚠️ The "secure cookie never appears" trap

Behind a proxy/load balancer, Express sees plain HTTP internally. With cookie.secure: true, it refuses to set the cookie because it thinks the connection isn't encrypted. Add app.set('trust proxy', 1) so Express honors the X-Forwarded-Proto header.

Summary

🎉 Key Takeaways

  • Sessions keep state on the server; the client holds only an opaque session ID in a signed cookie
  • express-session reads the cookie, loads req.session, and saves changes back to the store
  • Use resave: false, saveUninitialized: false, and a secret from the environment
  • Set httpOnly, secure, sameSite on the cookie
  • Regenerate the ID on login to stop fixation; destroy it on logout for instant revocation
  • Sessions make revocation easy but are stateful; JWTs are stateless but hard to revoke

📚 Additional Resources

🚀 What's Next?

Your sessions currently live in the default MemoryStore, which can't survive a restart or scale past one process. Next up: Session Stores — configuring Redis and MongoDB stores so your sessions are fast, persistent, and shared across every server.

🎉 Sessions online!

You can now log a user in, guard routes, and log them out — safely. Let's give those sessions a production-grade home.