Building the Cookie API on dfg.com

The complete Express server: CORS for credentials, register, login, session middleware, and the protected routes.

This is the whole API for version 1. Read it top to bottom — the ordering of the middleware is part of the design, not an accident.

The four things that make it work cross-site

  1. CORS with an exact origin and credentials: true. A wildcard is rejected by the browser the moment credentials are involved.
  2. The cookie is SameSite=None; Secure. Nothing less will be sent from abc.com to dfg.com.
  3. CORS is mounted before authentication, so the unauthenticated preflight is answered rather than rejected.
  4. The session id is looked up server-side on every request — the cookie carries no information of its own.

What the routes assume

Every handler under /api runs after session, so req.user.id is a verified identity. No handler reads a user id from the query string, the body, or a header. That single discipline is what stops one user reading another's notes.

The cookie, attribute by attribute

  • httpOnly — JavaScript on abc.com never sees it, so an XSS there cannot steal the session.
  • secure — HTTPS only, and mandatory alongside sameSite: 'none'.
  • sameSite: 'none' — required because the sites differ.
  • partitioned — CHIPS: gives the cookie its own jar under abc.com, which is where browsers are heading.
  • maxAge — a browser hint. The real expiry is the Redis TTL on the server.

Example

Example · javascript
// The cookie that makes cross-site auth possible at all
const COOKIE = 'sid';
const COOKIE_OPTIONS = {
  httpOnly: true,       // invisible to JavaScript
  secure: true,         // HTTPS only — required by sameSite:'none'
  sameSite: 'none',     // abc.com → dfg.com is cross-site
  partitioned: true,    // CHIPS: a jar per top-level site
  path: '/',
  maxAge: 30 * 60 * 1000,
};

When to use it

  • A backend team copies this server as the starting point for a cross-site session API and swaps the in-memory store for Redis.
  • A debugging session traces a missing cookie to CORS being mounted after the auth middleware, so preflights were returning 401.
  • A reviewer confirms no route reads a user id from client input, which is what the ownership guarantee rests on.

More examples

api/server.js — the complete cookie API

Four defences are woven in here without extra code: session id rotation on login, idle plus absolute timeouts, hashed session keys, and ownership checks inside the query.

Example · javascript
import express from 'express';
import https from 'https';
import fs from 'fs';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import { randomBytes, createHash } from 'crypto';
import { db, publicProfile } from './db.js';

const app = express();

const FRONTEND = 'https://abc.local:5173';        // https://abc.com in production
const COOKIE = 'sid';
const IDLE_MS = 30 * 60 * 1000;
const ABSOLUTE_MS = 12 * 60 * 60 * 1000;
const COOKIE_OPTIONS = {
  httpOnly: true, secure: true, sameSite: 'none', partitioned: true,
  path: '/', maxAge: IDLE_MS,
};

/* ---------------------------------------------------------------
   1. CORS FIRST. The preflight arrives with no cookie, so anything
      that can return 401 must be mounted after this.
   --------------------------------------------------------------- */
app.use(cors({
  origin(origin, cb) {
    if (!origin) return cb(null, true);                  // curl, server-to-server
    return origin === FRONTEND ? cb(null, true) : cb(new Error('origin_not_allowed'));
  },
  credentials: true,                                     // required for cookies
  methods: ['GET', 'POST', 'DELETE'],
  allowedHeaders: ['Content-Type', 'X-XSRF-TOKEN'],
  maxAge: 86400,
}));

app.use(express.json({ limit: '16kb' }));
app.use(cookieParser());
app.set('trust proxy', 1);

/* ---------------------------------------------------------------
   2. Session store. The cookie is a meaningless id; everything the
      server knows lives here, keyed by a HASH of that id.
   --------------------------------------------------------------- */
const sessions = new Map();                              // use Redis in production
const key = (sid) => createHash('sha256').update(sid).digest('hex');

function createSession(userId, req) {
  const sid = randomBytes(32).toString('base64url');      // 256 bits
  sessions.set(key(sid), {
    userId, createdAt: Date.now(), lastSeen: Date.now(),
    ip: req.ip, ua: req.get('user-agent'),
  });
  return sid;
}

function readSession(sid) {
  if (!sid) return null;
  const data = sessions.get(key(sid));
  if (!data) return null;

  const now = Date.now();
  if (now - data.lastSeen > IDLE_MS ||                    // idle timeout
      now - data.createdAt > ABSOLUTE_MS) {               // absolute timeout
    sessions.delete(key(sid));
    return null;
  }
  data.lastSeen = now;                                    // slide the idle clock
  return data;
}

function session(req, res, next) {
  const data = readSession(req.cookies[COOKIE]);
  if (!data) {
    res.clearCookie(COOKIE, { ...COOKIE_OPTIONS, maxAge: undefined });
    return res.status(401).json({ error: 'authentication_required' });
  }
  req.user = { id: data.userId };
  req.sid = req.cookies[COOKIE];
  next();
}

/* --------------------------- 3. Auth routes --------------------------- */
const authLimiter = rateLimit({ windowMs: 15 * 60e3, limit: 20,
  message: { error: 'too_many_attempts' } });

app.post('/auth/register', authLimiter, async (req, res) => {
  const { email, password } = req.body ?? {};
  if (!email || !password || String(password).length < 12) {
    return res.status(400).json({ error: 'email_and_12_char_password_required' });
  }
  try {
    const user = await db.createUser(email, password);
    res.cookie(COOKIE, createSession(user.id, req), COOKIE_OPTIONS);
    res.status(201).json({ user: publicProfile(user) });
  } catch {
    // Same message either way — do not confirm which emails are registered.
    res.status(400).json({ error: 'registration_failed' });
  }
});

app.post('/auth/login', authLimiter, async (req, res) => {
  const user = await db.verifyPassword(req.body?.email, req.body?.password);
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });

  // Discard any pre-existing session id: this is the session fixation defence.
  if (req.cookies[COOKIE]) sessions.delete(key(req.cookies[COOKIE]));

  res.cookie(COOKIE, createSession(user.id, req), COOKIE_OPTIONS);
  res.json({ user: publicProfile(user) });
});

app.post('/auth/logout', session, (req, res) => {
  sessions.delete(key(req.sid));                          // server side FIRST
  res.clearCookie(COOKIE, { ...COOKIE_OPTIONS, maxAge: undefined });
  res.status(204).end();
});

/* ------------------------- 4. Protected routes ------------------------ */
app.get('/api/me', session, (req, res) => {
  const user = db.findUserById(req.user.id);
  if (!user) return res.status(401).json({ error: 'authentication_required' });
  res.json({ user: publicProfile(user) });
});

app.get('/api/notes', session, (req, res) => {
  res.json({ notes: db.listNotes(req.user.id) });         // id from the SESSION
});

app.post('/api/notes', session, (req, res) => {
  const text = String(req.body?.text ?? '').trim();
  if (!text || text.length > 500) return res.status(400).json({ error: 'invalid_text' });
  res.status(201).json({ note: db.createNote(req.user.id, text) });
});

app.delete('/api/notes/:id', session, (req, res) => {
  // Ownership is inside the delete. A note you do not own is simply not found.
  return db.deleteNote(req.user.id, req.params.id)
    ? res.sendStatus(204)
    : res.status(404).json({ error: 'not_found' });
});

https.createServer({
  key: fs.readFileSync('../dfg.local-key.pem'),
  cert: fs.readFileSync('../dfg.local.pem'),
}, app).listen(8443, () => console.log('API on https://dfg.local:8443'));

Proving the cross-site rules to yourself

The last check is worth internalising: curl still receives the body, because CORS is enforced by browsers only. Never mistake it for an access control.

Example · bash
# The preflight — must succeed WITHOUT any cookie
curl -i -X OPTIONS https://dfg.local:8443/api/notes \
  -H "Origin: https://abc.local:5173" \
  -H "Access-Control-Request-Method: GET" --cacert ../rootCA.pem

# Access-Control-Allow-Origin: https://abc.local:5173
# Access-Control-Allow-Credentials: true      ← both required

# Log in and keep the cookie
curl -c jar.txt -X POST https://dfg.local:8443/auth/login \
  -H 'Content-Type: application/json' \
  -H 'Origin: https://abc.local:5173' \
  -d '{"email":"[email protected]","password":"correct horse battery"}' \
  --cacert ../rootCA.pem

grep sid jar.txt
# dfg.local  FALSE  /  TRUE  1754316000  sid  8f2bd1...

# Authenticated call
curl -b jar.txt https://dfg.local:8443/api/notes --cacert ../rootCA.pem

# An origin that is not on the allowlist gets no CORS headers at all
curl -i https://dfg.local:8443/api/notes -b jar.txt \
  -H "Origin: https://evil.local" --cacert ../rootCA.pem | grep -i access-control
# (nothing — so a browser would refuse to expose the response)

Discussion

  • Be the first to comment on this lesson.