auth · Lesson 2 · Node + Express

Cookies Done Right

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 app
The one idea

A 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.

01 What a cookie actually is

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)
});

02 The three flags, one at a time

HttpOnly — the anti-theft flag

An 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).

💡 This is the localStorage answer from Lesson 1 OWASP is blunt: never store session IDs, JWTs, or refresh tokens in 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 flag

A 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 valueSent on same-site requests?Sent on cross-site requests?Use when…
StrictYesNever (not even top-level nav)Max safety; OK if users don't follow inbound links into a logged-in area
Lax (default)YesOnly on top-level GET navigation (clicking a link)The sweet spot for session cookies — blocks cross-site POSTs
NoneYesYes — but requires SecureYou genuinely need third-party cross-site cookies (embeds, some SSO)
💡 The __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.

03 Stop hand-rolling: express-session

For 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');
});
⚠️ Two real-world footguns The default 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.

04 🎯 Your tangible win (5 min): run the cookie lab

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)
  1. Route / — 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.
  2. Route /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.
  3. Route /samesite — sets a Lax and a Strict cookie. Compare the SameSite column in the Application panel.
🎯 The win When you run 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.

05 CSRF: the attack SameSite was built to stop

Here'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.

evil.com hidden auto-POST Your browser holds bank cookie yourbank.com sees valid session ① triggers POST ② + bank cookie 😱 SameSite=Lax → not sent on cross-site POST → blocks this
The cookie is attached because the destination is the bank — the browser doesn't care who started the request. SameSite=Lax means "don't send on cross-site POSTs," which kills this.

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).

The fix, in two layers

Layer 1 — SameSite=Lax

Tells 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.

Layer 2 — a CSRF token

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.

⚠️ Don't reach for 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.

06 Production checklist for a credential cookie

HttpOnlyalways — JS must never read it
Securealways in prod — HTTPS only
SameSite=Laxdefault; Strict if you can
__Host- prefixfree hardening

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.

07 Check yourself

Q1. Your session cookie is HttpOnly. A reviewer says "great, so we're safe from CSRF." Right or wrong?

Show answer

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?

Show answer

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?

Show answer

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.)

08 Where we go next

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.

📌 Remember this one line A credential cookie is only as safe as its flags: 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.