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
- CORS with an exact origin and
credentials: true. A wildcard is rejected by the browser the moment credentials are involved. - The cookie is
SameSite=None; Secure. Nothing less will be sent fromabc.comtodfg.com. - CORS is mounted before authentication, so the unauthenticated preflight is answered rather than rejected.
- 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 onabc.comnever sees it, so an XSS there cannot steal the session.secure— HTTPS only, and mandatory alongsidesameSite: 'none'.sameSite: 'none'— required because the sites differ.partitioned— CHIPS: gives the cookie its own jar underabc.com, which is where browsers are heading.maxAge— a browser hint. The real expiry is the Redis TTL on the server.
Example
// 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.
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.
# 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