Skip to main content

04 - Secure Authentication: Session Management and Cookies

Sessions and cookies on a test app: id fixation, logout, attributes and expiry, scrypt passwords and replies that do not reveal who is registered.

What do you think about this article?

04 - Secure Authentication: Session Management and Cookies
Sicurezza WebPhoto:Bibek ghoshPexels

At a Glance

Sessions and cookies on a test app: id fixation, logout, attributes and expiry, scrypt passwords and replies that do not reveal who is registered.

After login, the server needs to remember who you are on every request, and the only thing that holds the requests together is a session identifier inside a cookie. Whoever steals it, guesses it or has you adopt it before login is, for the server, you. Here you build a small session and attack it: identifier fixation, a logout that closes nothing, cookies without the right attributes, sessions that never expire, badly stored passwords and error messages that reveal who is registered. Each has its test and its fix.

What a session promises

In the 2025 Top 10 these flaws sit in A07, Authentication Failures, whose page lists among the main weaknesses the use of hard-coded passwords (CWE-798), improper authentication (CWE-287) and session fixation (CWE-384). A session identifier, says the OWASP guide, must have at least 64 bits of entropy and come from a cryptographically secure random number generator; it must not contain user data, because its only job is to be impossible to predict. In the examples that follow the identifier is randomBytes(32) from node:crypto, that is, 256 bits.

Everything runs on test applications, in-process: Fastify 5.12.8's inject() (the version on the npm registry on 13 November 2026) delivers the request to the application without opening a port, and the sessions live in an in-memory Map. There is no browser: cookies travel because the script copies them into the header, as a browser would. When a test depends on a browser's behaviour, the text says so.

Session fixation and logout

In fixation the attacker visits the site, receives an anonymous identifier, and manages to have the victim adopt it (with a link, or by writing a cookie from a subdomain). If the server, at login, leaves the same session in place and merely marks it as authenticated, the attacker already holds the identifier of a session that is now authenticated. The OWASP guide prescribes renewing the identifier after every change of privilege, and login is the most important case. The script runs the scenario in four steps for two versions of the application: the decisive line is in the regenerateAtLogin branch, which deletes the old session and creates a new one.

import Fastify from 'fastify';
import { randomBytes } from 'node:crypto';

function build({ regenerateAtLogin }) {
  const sessions = new Map(); // sid -> { user | null }
  const fresh = () => randomBytes(32).toString('hex');
  const read = (req) => /(?:^|; )sid=([0-9a-f]+)/.exec(req.headers.cookie ?? '')?.[1];
  const app = Fastify();

  app.get('/', async (req, reply) => {          // whoever arrives without a session gets an anonymous one
    let sid = read(req);
    if (!sid || !sessions.has(sid)) {
      sid = fresh();
      sessions.set(sid, { user: null });
      reply.header('set-cookie', `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`);
    }
    return { sid };
  });
  app.post('/login', async (req, reply) => {   // credentials left out: only the session matters here
    let sid = read(req);
    if (!sid || !sessions.has(sid)) return reply.code(400).send({ error: 'no session' });
    if (regenerateAtLogin) {
      sessions.delete(sid);                     // the old session dies
      sid = fresh();
      sessions.set(sid, { user: 'alice' });
      reply.header('set-cookie', `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`);
    } else {
      sessions.get(sid).user = 'alice';         // same session, now authenticated
    }
    return { ok: true };
  });
  app.get('/account', async (req, reply) => {
    const s = sessions.get(read(req));
    return s?.user ? { user: s.user } : reply.code(401).send({ error: 'not authenticated' });
  });
  app.post('/logout', async (req) => {
    sessions.delete(read(req));                 // destroys the session on the server
    return { ok: true };
  });
  return app;
}

const cookieOf = (r) => r.headers['set-cookie']?.split(';')[0];
for (const regenerateAtLogin of [false, true]) {
  const app = build({ regenerateAtLogin });
  // 1. The attacker visits the site and gets an anonymous id; they manage to make the victim adopt it.
  const attackerSid = cookieOf(await app.inject({ url: '/' }));
  // 2. The victim, with that id, logs in.
  const login = await app.inject({ method: 'POST', url: '/login', headers: { cookie: attackerSid } });
  const victimCookie = cookieOf(login) ?? attackerSid;
  // 3. The attacker uses the id they already knew.
  const intrusion = await app.inject({ url: '/account', headers: { cookie: attackerSid } });
  console.log(`regenerate at login = ${regenerateAtLogin}: the id known to the attacker gives ${intrusion.statusCode} ${intrusion.body}`);
  // 4. Logout: the victim's cookie must no longer be valid.
  await app.inject({ method: 'POST', url: '/logout', headers: { cookie: victimCookie } });
  const after = await app.inject({ url: '/account', headers: { cookie: victimCookie } });
  console.log(`   after logout the old cookie gives ${after.statusCode}`);
}

// Output:
// regenerate at login = false: the id known to the attacker gives 200 {"user":"alice"}
//    after logout the old cookie gives 401
// regenerate at login = true: the id known to the attacker gives 401 {"error":"not authenticated"}
//    after logout the old cookie gives 401

Without regeneration the identifier the attacker knew gives 200 and the victim's name; with regeneration it gives 401, because that session no longer exists and the victim has a new one. The second experiment concerns logout: the script calls sessions.delete(...) on the server, and the old cookie gives 401 in both cases. The opposite risk is a logout that merely has the browser delete the cookie: whoever copied the value beforehand can reuse it as long as the session exists on the server, and this is why the guide asks to invalidate the session on both sides, with the server side as the indispensable part.

Cookie attributes and expiry

The Set-Cookie header carries the name, the value and a series of attributes that the browser applies. The table says what each does and what happens if you forget it, according to MDN and the OWASP guide.

AttributeWhat it doesIf it is missing
HttpOnlyForbids JavaScript access to the cookie (document.cookie)An injected script can read the identifier
SecureThe cookie travels only over https: (localhost excluded)It can be sent in clear and intercepted
SameSiteStrict, Lax or None: whether to attach it to cross-site requestsIt depends on the browser, with a more permissive Lax (see article 02)
DomainExtends the cookie to the domain and all its subdomainsThe cookie goes only to the server that issued it: it is the safest choice
Path=/ and the __Host- prefixThe prefix makes the browser reject the cookie unless it is Secure, has no Domain and has Path=/A subdomain can set a cookie with the same name (forgery from the subdomain)
Max-AgeSeconds of life in the browserA browser session cookie, which disappears when the browser closes (but the server must expire the session anyway)

Two important clarifications. HttpOnly prevents reading the cookie, not using it: MDN warns that an HttpOnly cookie is still sent with requests started by JavaScript, so an XSS can make requests "on behalf of" the user even without stealing the identifier; this is why the defences in article 02 remain necessary. And Secure does not protect against everything: MDN points out that the cookie can be read or modified with access to the client's disk. The following script builds the cookie with the __Host- prefix and checks it, then expires the session with a fake clock that decides how many minutes have passed.

import Fastify from 'fastify';
import { randomBytes } from 'node:crypto';

const IDLE_MS = 15 * 60 * 1000;           // maximum inactivity
const ABSOLUTE_MS = 8 * 60 * 60 * 1000;   // maximum lifetime, whatever the activity

function build(clock) {
  const sessions = new Map();
  const app = Fastify();
  app.post('/login', async (req, reply) => {
    const sid = randomBytes(32).toString('hex');
    sessions.set(sid, { created: clock(), last: clock(), user: 'alice' });
    // __Host-: the browser accepts the cookie only if Secure, without Domain and with Path=/
    reply.header('set-cookie', `__Host-sid=${sid}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${ABSOLUTE_MS / 1000}`);
    return { ok: true };
  });
  app.get('/account', async (req, reply) => {
    const sid = /(?:^|; )__Host-sid=([0-9a-f]+)/.exec(req.headers.cookie ?? '')?.[1];
    const s = sessions.get(sid);
    const now = clock();
    if (s && (now - s.last > IDLE_MS || now - s.created > ABSOLUTE_MS)) {
      sessions.delete(sid);                      // expired: it is really removed from the server
      return reply.code(401).send({ error: 'session expired' });
    }
    if (!s) return reply.code(401).send({ error: 'not authenticated' });
    s.last = now;                                // activity only moves the inactivity limit
    return { user: s.user };
  });
  return app;
}

let now = 0; // fake clock: the times are the ones we decide, not the machine's
const app = build(() => now);
const login = await app.inject({ method: 'POST', url: '/login' });
const header = login.headers['set-cookie'];
const cookie = header.split(';')[0];
const attributes = header.split('; ').slice(1);
console.log('Set-Cookie:', header.replace(/sid=[0-9a-f]+/, 'sid=<id>'));
console.log('__Host- rules respected:', header.startsWith('__Host-') && attributes.includes('Secure') && attributes.includes('Path=/') && !attributes.some((a) => /^Domain=/i.test(a)));
console.log('HttpOnly:', attributes.includes('HttpOnly'), '| SameSite:', attributes.find((a) => a.startsWith('SameSite')));

const ask = async (minutes) => {
  now = minutes * 60 * 1000;
  const r = await app.inject({ url: '/account', headers: { cookie } });
  return `${r.statusCode} ${r.body}`;
};
console.log('minute 10  :', await ask(10));
console.log('minute 20  :', await ask(20), '(10 minutes after the last activity)');
const app2 = build(() => now);
now = 0;
const c2 = (await app2.inject({ method: 'POST', url: '/login' })).headers['set-cookie'].split(';')[0];
let outcome = '';
for (let m = 10; m <= 8 * 60 + 10; m += 10) { // a user active every 10 minutes for 8 hours and 10 minutes
  now = m * 60 * 1000;
  const r = await app2.inject({ url: '/account', headers: { cookie: c2 } });
  outcome = `minute ${m}: ${r.statusCode}`;
  if (r.statusCode !== 200) break;
}
console.log('always-active user, last outcome ->', outcome);

// Output:
// Set-Cookie: __Host-sid=<id>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=28800
// __Host- rules respected: true
// HttpOnly: true | SameSite: SameSite=Lax
// minute 10  : 200 {"user":"alice"}
// minute 20  : 200 {"user":"alice"} (10 minutes after the last activity)
// always-active user, last outcome -> minute 490: 401

The first part of the output verifies the prefix's three rules on the header the application really issued, a simple way to put them in an automated test. The second applies the two limits the OWASP guide asks of every session: the inactivity limit (here 15 minutes, but the guide indicates 2-5 minutes for high-value applications and 15-30 for low-risk ones) and the absolute limit (here 8 hours, in the 4-8 hour range the guide cites for an application used by an office worker throughout the day). At 10 and 20 minutes the user is still in because 10 minutes pass between one request and the next; the user who asks every 10 minutes is thrown out anyway at 490 minutes, because the 8 hours since creation have been exceeded. A detail that is forgotten: expiry is applied on the server (the session is deleted), it does not rely on the browser's Max-Age, which can be ignored.

Passwords: how they are stored and what you answer

A real login requires storing passwords in such a way that a database theft does not hand them over (and reading them with parameterised queries, as in article 03). The OWASP guide recommends Argon2id; if it is not available, scrypt with a minimum cost N=2^17, block size r=8 and parallelism p=1, which requires 128 MiB of memory for each computation. node:crypto offers scrypt with no dependencies, and it is what the script uses, with two traps that appear when you run it: Node's default memory limit rejects N=2^17 with an error (maxmem), and the stored string must carry the parameters and the salt with it, so that you can raise them in future without losing compatibility.

import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';

// Minimum parameters indicated by OWASP for scrypt: N=2^17, r=8, p=1.
const N = 2 ** 17, r = 8, p = 1, LENGTH = 32;
const maxmem = 256 * 1024 * 1024; // Node's default limit (32 MiB) rejects N=2^17

function hashPassword(password) {
  const salt = randomBytes(16);
  const derived = scryptSync(password, salt, LENGTH, { N, r, p, maxmem });
  // The parameters are in the string: you can raise them later and still verify the old hashes.
  return ['scrypt', N, r, p, salt.toString('base64'), derived.toString('base64')].join('$');
}

function verify(password, stored) {
  const [scheme, n, rr, pp, salt, expected] = stored.split('$');
  if (scheme !== 'scrypt') return false;
  const derived = scryptSync(password, Buffer.from(salt, 'base64'), LENGTH,
    { N: Number(n), r: Number(rr), p: Number(pp), maxmem });
  const a = Buffer.from(expected, 'base64');
  return a.length === derived.length && timingSafeEqual(a, derived);
}

try {
  scryptSync('x', 'salt', 32, { N, r, p }); // without maxmem
} catch (e) {
  console.log('without maxmem:', e.code, '-', e.message);
}
const h1 = hashPassword('correct horse battery staple');
const h2 = hashPassword('correct horse battery staple');
console.log('format:', h1.split('$').slice(0, 4).join('$') + '$<salt>$<hash>');
console.log('right password:', verify('correct horse battery staple', h1));
console.log('wrong password:', verify('correct horse battery stapler', h1));
console.log('same password, different hashes (random salt):', h1 !== h2);
console.log('length of the stored string:', h1.length, 'characters');

// Output:
// without maxmem: ERR_CRYPTO_INVALID_SCRYPT_PARAMS - Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceeded
// format: scrypt$131072$8$1$<salt>$<hash>
// right password: true
// wrong password: false
// same password, different hashes (random salt): true
// length of the stored string: 87 characters

The ERR_CRYPTO_INVALID_SCRYPT_PARAMS error comes from the line with scryptSync without maxmem: the default limit is 32 MiB, and the maxmem option raises it to 256 MiB, the value needed for N=2^17. The salt is random for each password, so two users with the same password have different hashes; timingSafeEqual compares the two hashes without the time depending on the first differing byte. The time of one computation: over 12 runs, on a development computer with Node 24.11.0, I measured between 317 and 369 ms (measured for the Italian version of this article and carried over, not measured again for this one): it changes from machine to machine, and that is the cost an attacker pays too for every attempt. For the choice between algorithms, and the reason for the cost, see article 07.

For the rules on passwords themselves, the reference document is NIST SP 800-63B-4: it requires at least 15 characters for passwords used as the only factor (8 if they are part of multi-factor authentication), recommends allowing a maximum length of at least 64 characters, forbids composition rules (mandatory capitals, symbols) and forbids imposing periodic changes, except on evidence of compromise. One last flaw remains: the error message. The OWASP authentication guide asks for a generic response, one that does not change whether the user does not exist, the password is wrong or the account is locked. The script compares the two versions.

import Fastify from 'fastify';
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';

// Reduced cost for the demo only: in production use the parameters of the previous example.
const PARAMS = { N: 2 ** 14, r: 8, p: 1 };
const hashOf = (pw, salt) => scryptSync(pw, salt, 32, PARAMS);
const FAKE_SALT = randomBytes(16);
const FAKE_HASH = hashOf('a-password-nobody-will-use', FAKE_SALT);

const users = new Map();
const register = (email, pw) => {
  const salt = randomBytes(16);
  users.set(email, { salt, hash: hashOf(pw, salt) });
};
register('alice@example.test', 'a long and unique phrase');

function build(uniform) {
  const app = Fastify();
  let comparisons = 0;
  app.post('/login', async (req, reply) => {
    const { email, password } = req.body;
    const u = users.get(email);
    if (!uniform && !u) return reply.code(404).send({ error: 'user does not exist' });
    // Uniform: for a nonexistent user too, the same work is done and the same answer is given.
    const salt = u ? u.salt : FAKE_SALT;
    const expected = u ? u.hash : FAKE_HASH;
    comparisons += 1;
    const ok = timingSafeEqual(hashOf(password, salt), expected) && Boolean(u);
    return ok ? { ok: true } : reply.code(401).send({ error: 'invalid credentials' });
  });
  return { app, comparisons: () => comparisons };
}

for (const uniform of [false, true]) {
  const { app, comparisons } = build(uniform);
  const post = (email, password) => app.inject({ method: 'POST', url: '/login', payload: { email, password } });
  const existing = await post('alice@example.test', 'wrong');
  const nonexistent = await post('nobody@example.test', 'wrong');
  const valid = await post('alice@example.test', 'a long and unique phrase');
  console.log(uniform ? '--- uniform response' : '--- different responses');
  console.log('existing user, wrong password:', existing.statusCode, existing.body);
  console.log('nonexistent user             :', nonexistent.statusCode, nonexistent.body);
  console.log('same response (users are not enumerated):', existing.body === nonexistent.body && existing.statusCode === nonexistent.statusCode);
  console.log('valid login:', valid.statusCode, '| hash computations performed:', comparisons());
}

// Output:
// --- different responses
// existing user, wrong password: 401 {"error":"invalid credentials"}
// nonexistent user             : 404 {"error":"user does not exist"}
// same response (users are not enumerated): false
// valid login: 200 | hash computations performed: 2
// --- uniform response
// existing user, wrong password: 401 {"error":"invalid credentials"}
// nonexistent user             : 401 {"error":"invalid credentials"}
// same response (users are not enumerated): true
// valid login: 200 | hash computations performed: 3

In the naive version the application answers 404 "user does not exist" and 401 "invalid credentials": an attacker discovers which addresses are registered. In the uniform version the two cases give the same response, and the script does the hash computation even for the user who does not exist, with a fake hash; you can see it from the counter, which goes from 2 to 3 computations for the same three requests. This serves to make the work done equal too: with a slow hash, a nonexistent user resolved immediately could give itself away by the response time. I did not measure those times, only counted the computations. Uniformity also applies to registration and password recovery, as the same guide reminds.

Frequently asked questions

Better a server-side session or a JWT?

For a website with login, a server-side session with a cookie is the simplest choice to revoke: logout deletes the session, and a stolen identifier stops being valid. JWTs are for other scenarios and carry other risks; article 05 covers them.

Is HttpOnly enough to protect the session from an XSS?

It protects against theft of the value, not against use: an injected script can make requests that the browser accompanies with the cookie. You also need output encoding and a CSP, as in article 02.

How long should a session last?

It depends on the risk. The OWASP guide indicates 2-5 minutes of inactivity for high-value applications and 15-30 for low-risk ones, with an absolute limit that follows real use, for example 4-8 hours for an office worker. The values in the example are a choice, not a rule.

What you can do now

You can reproduce a session fixation and close it by regenerating the identifier at login, write a logout that destroys the session on the server and check the cookie attributes with a test, the __Host- prefix included. You can expire a session by inactivity and by absolute duration, store a password with scrypt without forgetting maxmem and a salt, and answer uniformly so as not to reveal who is registered. Where the session is not enough, for APIs and tokens, continue with article 05.

Sources

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects