← Recipes

阅读中文版本

Harden Session Cookies Against Theft and CSRF

Set HttpOnly, Secure, SameSite, and __Host- cookie attributes, rotate session IDs on login, and verify that browsers reject unsafe cookie writes.

~40 minsecuritycookiessessions

Task

Configure Express session cookies with HttpOnly, Secure, SameSite=Lax, a __Host- prefix, and session ID rotation on login.

When to use this

A session cookie is readable by JavaScript and sent on cross-site form posts, exposing accounts to XSS token theft and CSRF.

Prerequisites

  • Node.js 20+ with Express 4 or 5, express-session 1.18+, and connect-redis 7.x
  • A running Redis 7 instance and an existing users table with argon2 password hashes
  • HTTPS in every environment, including local development with a trusted certificate
  • Correct knowledge of how many reverse proxy hops sit in front of Express

Steps

1. Install the session store, Redis client, and password verification library.

pnpm add express-session connect-redis redis argon2

Expected output: The packages are added to package.json and the lockfile; Express and node-postgres are already present.

2. Configure the session middleware with a __Host- cookie name, Redis storage, and attributes that forbid JavaScript access and cross-site sending.

import express from 'express';
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';

const app = express();
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS ?? 1));
app.use(express.json());

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

app.use(
  session({
    name: '__Host-sid',
    secret: process.env.SESSION_SECRET!,
    store: new RedisStore({ client: redis, prefix: 'sess:' }),
    resave: false,
    saveUninitialized: false,
    rolling: true,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      path: '/',
      maxAge: 8 * 60 * 60 * 1000,
    },
  }),
);

Expected output: Set-Cookie uses the __Host-sid name with HttpOnly, Secure, SameSite=Lax, and Path=/; no Domain attribute is emitted.

3. Regenerate the session after verifying credentials and destroy it on logout so a pre-login ID cannot become an authenticated session.

import argon2 from 'argon2';
import pg from 'pg';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

declare module 'express-session' {
  interface SessionData {
    userId: string;
  }
}

app.post('/login', async (req, res, next) => {
  const { rows } = await pool.query(
    'SELECT id, password_hash FROM app.users WHERE email = $1',
    [req.body.email],
  );
  const user = rows[0];

  if (!user || !(await argon2.verify(user.password_hash, req.body.password))) {
    res.status(401).json({ error: 'invalid credentials' });
    return;
  }

  req.session.regenerate((error) => {
    if (error) {
      next(error);
      return;
    }

    req.session.userId = user.id;
    req.session.save((saveError) => {
      if (saveError) {
        next(saveError);
        return;
      }
      res.json({ ok: true });
    });
  });
});

app.post('/logout', (req, res, next) => {
  req.session.destroy((error) => {
    if (error) {
      next(error);
      return;
    }
    res.clearCookie('__Host-sid', { path: '/' });
    res.status(204).end();
  });
});

Expected output: A successful login issues a new session ID, and logout destroys the Redis record and clears the cookie.

4. Insert a same-origin check for state-changing requests before the login and logout routes, as defense in depth beyond SameSite.

const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN;
if (!PUBLIC_ORIGIN) throw new Error('PUBLIC_ORIGIN is required');

app.use((req, res, next) => {
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
    next();
    return;
  }
  if (req.get('origin') !== PUBLIC_ORIGIN) {
    res.status(403).json({ error: 'cross-origin request blocked' });
    return;
  }
  next();
});

Expected output: A POST with Origin https://evil.example returns 403; a POST from PUBLIC_ORIGIN reaches the route.

5. Inspect the login response headers and confirm every required cookie attribute is present.

curl -sS -D - -o /dev/null https://localhost:3000/login \
  --insecure \
  -H 'origin: https://localhost:3000' \
  -H 'content-type: application/json' \
  -d '{"email":"[email protected]","password":"correct horse battery staple"}' \
  | grep -i '^set-cookie:'

Expected output: The header starts with set-cookie: __Host-sid= and includes Path=/, HttpOnly, Secure, and SameSite=Lax with no Domain attribute.

6. Verify that a cross-site POST is rejected before it can reach a state-changing route.

curl -sS -o /dev/null -w '%{http_code}\n' https://localhost:3000/logout \
  --insecure -X POST -H 'origin: https://evil.example'

Expected output: The response status is 403 and the session is not destroyed.

7. Log in twice with the same cookie jar and compare the session IDs to prove regeneration.

curl -sS -c /tmp/sid.txt https://localhost:3000/login \
  --insecure -H 'origin: https://localhost:3000' \
  -H 'content-type: application/json' \
  -d '{"email":"[email protected]","password":"correct horse battery staple"}' >/dev/null
awk '$6 == "__Host-sid" { print $7 }' /tmp/sid.txt

curl -sS -b /tmp/sid.txt -c /tmp/sid.txt https://localhost:3000/login \
  --insecure -H 'origin: https://localhost:3000' \
  -H 'content-type: application/json' \
  -d '{"email":"[email protected]","password":"correct horse battery staple"}' >/dev/null
awk '$6 == "__Host-sid" { print $7 }' /tmp/sid.txt

Expected output: The two printed session IDs are different, confirming that each login regenerates the session.

8. Open an authenticated page in a browser, run document.cookie in DevTools, and inspect the cookie in Application > Cookies.

Expected output: document.cookie does not contain the __Host-sid value, while the cookie table shows HttpOnly and Secure enabled.

Troubleshooting

Problem

No Set-Cookie header appears at all

Solution

secure: true requires HTTPS end to end. Check the TLS terminator, TRUST_PROXY_HOPS, and whether Express sees the request as secure.

Problem

The browser refuses to store the __Host- cookie

Solution

A __Host- cookie must be Secure, have Path=/, and omit Domain. Removing a Domain attribute added by a proxy or framework fixes it.

Problem

Same-origin POSTs return 403

Solution

The browser omitted Origin or PUBLIC_ORIGIN uses a different scheme, host, or port. Log the Origin header and compare exact strings.

Problem

Users are logged out after every deploy

Solution

The session store is in memory or the SESSION_SECRET changed. Use Redis and keep the secret stable across rolling instances.

Limitations

  • HttpOnly prevents JavaScript from reading the cookie but not from issuing authenticated requests during an XSS attack; add a strict CSP.
  • SameSite=Lax does not protect against same-site subdomain attacks; the Origin check is still required.
  • Cookie maxAge and Redis session TTL must stay aligned; changing one without the other creates either stale records or premature logouts.

HttpOnly blocks JavaScript from reading the cookie, which limits the damage of an XSS bug but does not prevent the injected script from making authenticated requests. Secure keeps the cookie off plaintext HTTP. SameSite=Lax stops most cross-site POSTs from carrying the cookie, but it does not cover every same-site scenario. The __Host- prefix adds a browser-enforced guarantee: the cookie must be Secure, must have Path=/, and must not set Domain. Together these attributes make theft and cross-site reuse harder, but each one covers a different failure.

The prefix is also a deployment constraint. A cookie named __Host-sid is rejected if it lacks Secure or sets a Domain, so local HTTP development cannot use the same name. Run local development behind HTTPS with a trusted local certificate, or use a different cookie name in development and accept that the development configuration is less strict. Do not weaken production to match a local setup.

Rotate at the authentication boundary

Session fixation works when an attacker can choose or learn a session ID before the victim logs in, then reuse it afterward. Regenerating the session on login invalidates the old ID and binds the authenticated state to a new one. express-session exposes regenerate, and the callback must complete before setting userId. Saving explicitly before responding avoids a race where the client receives a success response but the session has not reached Redis. Logout should destroy the server-side record, not only clear the cookie; a stolen cookie remains valid until the store record is gone.

rolling: true refreshes the cookie expiration on activity, which is convenient for active users but can keep a session alive indefinitely. Pair it with an absolute server-side timeout if the application requires re-authentication after a fixed period. The Redis store TTL should match the cookie maxAge; a longer store TTL leaves orphaned sessions, while a shorter one logs users out before the browser forgets the cookie.

CSRF defense in depth

SameSite=Lax is a strong default, but it is not a complete CSRF defense. Same-site subdomains, older browsers, and applications that relax the attribute can still be attacked. The Origin check rejects state-changing requests whose Origin does not exactly match the public origin. It fails closed when PUBLIC_ORIGIN is missing, which is preferable to silently accepting all origins. Some clients omit Origin; those requests are rejected, so API clients must send the header or use a separate token-based authentication path.

A double-submit CSRF token or a synchronizer token is stronger for browser forms, but the Origin check is cheap and catches the common cross-site case. Do not rely on a custom header alone; an attacker who can run JavaScript on the origin can set it, and that is already an XSS emergency.

Verify from the wire and the browser

curl -D - shows the exact Set-Cookie header, including attributes that a framework might add or omit. The cross-site POST check proves the Origin middleware runs before the logout handler. The two-login sequence proves regeneration rather than assuming the callback is wired. Finally, document.cookie in the browser proves HttpOnly from the client side; the cookie table confirms Secure and the prefix constraints.

Pitfalls

Do not store session data in a signed cookie without encryption if it contains sensitive fields. Do not use a short or shared SESSION_SECRET; rotating it invalidates every session, so plan the rollout. Do not set sameSite: 'none' to make a cross-site embed work without understanding the consequences; it requires Secure and reopens CSRF exposure. And do not forget to clear the cookie with the same path when logging out, or the browser may keep sending a stale value.

Example

import express from 'express';
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';

const app = express();
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS ?? 1));
app.use(express.json());

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

app.use(
  session({
    name: '__Host-sid',
    secret: process.env.SESSION_SECRET!,
    store: new RedisStore({ client: redis, prefix: 'sess:' }),
    resave: false,
    saveUninitialized: false,
    rolling: true,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      path: '/',
      maxAge: 8 * 60 * 60 * 1000,
    },
  }),
);