Last lesson: the credential that keeps you logged in is carried in a cookie. A cookie is just a string the browser re-sends automatically — all of its security lives in a handful of flags. Three flags and one prefix decide whether a single XSS bug or a malicious link can hijack your users. Today we make every flag concrete in real Express, and watch a CSRF attack get killed by one of them.
🎯 Mission: a mental model of modern auth + the ability to wire it into a real appA cookie's flags, not its contents, are what make it safe. HttpOnly stops JavaScript (and thus XSS) from reading it. Secure stops it leaking over plain HTTP. SameSite controls whether it rides along on cross-site requests — which is what blunts CSRF. Ship a credential cookie without these and you've handed it away. In Express, that's a few keys in one options object.
A cookie is a tiny key=value string. The server sends Set-Cookie: once; the browser stores it and automatically attaches it to every future request to that site — you don't write any code for the re-sending. That automatic-attachment is the whole point (it's how you stay logged in) and the whole danger (the browser attaches it even when a malicious site triggered the request — that's CSRF, §05).
// Server → browser (once): Set-Cookie: sid=8f4e2a91c7b6d05f; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600 // Browser → server (every later request, automatically): Cookie: sid=8f4e2a91c7b6d05f
In Express you never hand-write that header. res.cookie(name, value, options) builds it, and the options object is the security surface:
res.cookie('sid', sessionId, { httpOnly: true, // JS can't read it (XSS defense) secure: true, // HTTPS-only (don't leak over http) sameSite: 'lax', // cross-site send rule (CSRF defense) maxAge: 1000*60*10, // ms in Express (note: raw header is SECONDS) });
HttpOnly — the anti-theft flagAn HttpOnly cookie is invisible to JavaScript: document.cookie won't show it. That matters because the most common way credentials get stolen is XSS — an attacker injects JS into your page. If your session cookie is HttpOnly, that injected JS still can't read it. This single flag is why session IDs live in cookies and not in localStorage (which is always readable by JS).
localStorage/sessionStorage — "the data is always accessible by JavaScript… one XSS vulnerability discloses every token." An HttpOnly cookie is the mitigation. See OWASP Session Management.Secure — the HTTPS-only flagA Secure cookie is only ever sent over HTTPS. Without it, a single plain-http:// request (a stray link, a downgrade) leaks the credential in cleartext to anyone on the network. Always true in production. The one exception is local dev on http://localhost — that's why the lab below sets secure:false with a loud comment.
SameSite — the cross-site flag (the CSRF lever)This one decides whether the browser attaches the cookie when the request originated from a different site. It's the most important and least understood flag, so it gets its own section (§05). Three values:
| SameSite value | Sent on same-site requests? | Sent on cross-site requests? | Use when… |
|---|---|---|---|
Strict | Yes | Never (not even top-level nav) | Max safety; OK if users don't follow inbound links into a logged-in area |
Lax (default) | Yes | Only on top-level GET navigation (clicking a link) | The sweet spot for session cookies — blocks cross-site POSTs |
None | Yes | Yes — but requires Secure | You genuinely need third-party cross-site cookies (embeds, some SSO) |
__Host- prefix — a free upgrade
Name your cookie __Host-sid and the browser enforces that it's Secure, has Path=/, and has no Domain — so a compromised subdomain can't overwrite it. Pure win for credential cookies, costs one prefix. See MDN — cookie prefixes.express-sessionFor the stateful session design from Lesson 1, you don't set the cookie yourself — the express-session middleware does. You give it cookie options once; it generates the opaque ID, stores the real data server-side, and attaches/reads the cookie on every request. This is the boring, recommended default for an Express web app.
const session = require('express-session'); app.use(session({ name: '__Host-sid', // prefixed credential cookie secret: process.env.SESSION_SECRET, // signs the cookie (tamper-evident) resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600000 }, // store: new RedisStore(...) ← prod. The default MemoryStore is DEV-ONLY. })); app.post('/login', (req, res) => { // after you've verified the password… req.session.userId = user.id; // truth lives server-side, keyed by the id res.redirect('/account'); });
MemoryStore leaks memory and forgets every session on restart — fine for the lab, never for production (use connect-redis or a DB store). And secure:true behind a proxy/load-balancer needs app.set('trust proxy', 1) or the cookie silently won't set. See express-session docs.I've put a tiny, ready-to-run Express app on your machine that sets the same cookie three different ways so you can watch the flags behave. Dependencies are already installed.
# in your terminal: cd ~/projects/learn/public/courses/auth/practice/cookie-lab npm start # → open http://localhost:3000 (DevTools → Application → Cookies)
/ — sets an HttpOnly session id (sid). In the Console run document.cookie → sid is missing. That's HttpOnly working: JS literally cannot see your credential./insecure — sets readable_token with httpOnly:false. Run document.cookie again → now you see it. That's exactly how XSS exfiltrates a credential. Feel the difference one flag makes./samesite — sets a Lax and a Strict cookie. Compare the SameSite column in the Application panel.document.cookie and see readable_token but not sid, you've directly observed the single most important defense in browser auth — and you can now justify "credentials go in HttpOnly cookies, never localStorage" from something you saw, not something you read. Edit server.js, flip a flag, restart, re-observe. The file is yours to break.
SameSite was built to stopHere's the problem the automatic-attachment of cookies creates. You're logged into yourbank.com (so your browser holds its session cookie). You visit evil.com. Its page contains a hidden form that auto-submits a POST to yourbank.com/transfer. The browser attaches your bank cookie to that request — because it's going to the bank — even though evil.com triggered it. The bank sees a valid session and processes the transfer. That's Cross-Site Request Forgery.
Note CSRF doesn't need to read anything — the attacker never sees your cookie, they just get it used. That's why HttpOnly doesn't help here, and why CSRF is purely a problem for the cookie (automatic) carrier, not the Authorization: Bearer header (which JS attaches deliberately, so a cross-site page can't replicate it).
SameSite=LaxTells the browser not to send the cookie on cross-site POSTs. Kills the classic attack above and it's the modern default. Set it and you've covered the common case for free.
A secret value the server plants and the form must echo back. evil.com can't read or guess it, so its forged POST fails. Defense-in-depth for older browsers, embedded webviews, and SameSite=None flows.
csurf — it's dead
The classic csurf package was deprecated and archived (May 2025) and gets no security fixes. For the token layer use a maintained lib like csrf-csrf (signed double-submit pattern), and treat SameSite as the first line, not the only line. See OWASP CSRF Cheat Sheet.Plus: a real session store (not MemoryStore), a sensible maxAge, and a CSRF token on state-changing routes. That's a correctly-shipped session cookie — nothing exotic, just the flags applied deliberately.
Q1. Your session cookie is HttpOnly. A reviewer says "great, so we're safe from CSRF." Right or wrong?
Wrong. HttpOnly stops JS from reading the cookie (XSS theft). CSRF never reads the cookie — it gets the browser to send it on a forged request. Different attack, different flag: SameSite (+ a CSRF token) is what addresses CSRF. (§05.)
Q2. Why is a session cookie safer than putting the same session ID in localStorage?
localStorage is always readable by JavaScript, so any XSS bug leaks it. A cookie can be HttpOnly, making it invisible to JS — XSS can't exfiltrate it. You verified this in the lab: document.cookie showed readable_token but not sid. (§02, §04.)
Q3. You set SameSite=None on a cookie and it stops working entirely. Likely cause?
SameSite=None requires the Secure flag — browsers reject a None cookie that isn't also Secure (and you need HTTPS). Add secure:true over HTTPS. (§02 table.)
You can now ship the stateful half of Lesson 1 correctly. Next we open the stateless half: JWT anatomy — what those three dot-separated chunks actually are, what "signed" buys you, the infamous alg:none and algorithm-confusion attacks, and when a JWT is the right tool vs a costly mistake. We'll decode a real one in Node.
HttpOnly (no JS theft) + Secure (no cleartext) + SameSite=Lax (no cross-site CSRF) — set all three, deliberately, every time.💬 Ask me anything — e.g. "wire up csrf-csrf in the lab", "show me the Redis store version", or "how does this change for an SPA + API on two domains?" (great question — it's where cookies get genuinely tricky). · Back to Lesson 1 · Resources.