How to check session fixation

Plant a value, sign in carrying it, then spend it. curl -b 'sid=planted0000000000000000000000000000' http://127.0.0.1:8975/login-vulnerable returns that same identifier in Set-Cookie, and the planted value afterwards answers alice role=user. A server that resists issues a different identifier and refuses the planted one with 401. Run it on your own build.

Why check this

Run this on every release that touches the login handler, the session middleware or the single sign-on integration, and after a framework upgrade, because the call that regenerates an identifier is the one a migration drops. The failure it prevents is an account takeover with no password: a value placed in a browser before login keeps working after it.

The check is defensive and runs against something you own. Nothing here guesses or intercepts an identifier belonging to anyone else. You supply a value to your own service, sign in with your own credentials, and read whether it kept the value. The server answers one question about itself: does the identifier change when its privilege changes.

Prerequisites

// session-fixation.js - local target for the session fixation check. Node 22, no dependencies.
const http = require('node:http');
const crypto = require('node:crypto');

const sessions = new Map(); // sid -> { user, role }
const text = { 'content-type': 'text/plain' };
const sid = (req) => (/(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') || [])[1] || null;
const newId = () => crypto.randomBytes(16).toString('hex');
const cookie = (id) => `sid=${id}; Path=/; HttpOnly; SameSite=Lax`;

http.createServer((req, res) => {
  const p = new URL(req.url, 'http://127.0.0.1').pathname;
  const present = sid(req);

  // An anonymous session on any identifier the client offers. This is the plant.
  if (p === '/start') {
    const id = present || newId();
    sessions.set(id, { user: null, role: 'anonymous' });
    return res.writeHead(200, { ...text, 'set-cookie': cookie(id) }).end(`anonymous session ${id}\n`);
  }

  // Defect A: the login binds the account to whatever identifier arrived.
  if (p === '/login-vulnerable') {
    const id = present || newId();
    sessions.set(id, { user: 'alice', role: 'user' });
    return res.writeHead(200, { ...text, 'set-cookie': cookie(id) }).end(`signed in on ${id}\n`);
  }

  // Defect B: a new identifier is issued and the old one is never invalidated.
  if (p === '/login-rotate-only') {
    const s = { user: 'alice', role: 'user' };
    const id = newId();
    sessions.set(id, s);
    if (present) sessions.set(present, s); // the planted id still resolves to this session
    return res.writeHead(200, { ...text, 'set-cookie': cookie(id) }).end(`signed in on ${id}\n`);
  }

  // Fixed: new identifier, old record dropped.
  if (p === '/login-fixed') {
    if (present) sessions.delete(present);
    const id = newId();
    sessions.set(id, { user: 'alice', role: 'user' });
    return res.writeHead(200, { ...text, 'set-cookie': cookie(id) }).end(`signed in on ${id}\n`);
  }

  // A privilege change that does not touch the identifier.
  if (p === '/elevate') {
    const s = present && sessions.get(present);
    if (!s || !s.user) return res.writeHead(401, text).end('Not signed in\n');
    s.role = 'admin';
    return res.writeHead(200, text).end(`role now ${s.role} on ${present}\n`);
  }

  if (p === '/whoami') {
    const s = present && sessions.get(present);
    if (!s) return res.writeHead(401, text).end('Not signed in\n');
    return res.writeHead(200, text).end(`${s.user ?? 'anonymous'} role=${s.role}\n`);
  }

  return res.writeHead(404, text).end('Not found\n');
}).listen(8975, () => console.log('session fixation demo on http://127.0.0.1:8975'));

Start it. When you finish, stop the one process holding port 8975, never every process named node.

node session-fixation.js

Steps

  1. Step 1.

    Plant an identifier of your choosing.

    curl -s -i -b 'sid=planted0000000000000000000000000000' http://127.0.0.1:8975/start | head -3
    
    HTTP/1.1 200 OK
    content-type: text/plain
    set-cookie: sid=planted0000000000000000000000000000; Path=/; HttpOnly; SameSite=Lax

    The server echoed the value back instead of replacing it. Accepting a client-chosen identifier is the precondition for the attack, and a finding on its own.

  2. Step 2.

    Check what it is worth before login.

    curl -s -w '\nstatus %{http_code}\n' -b 'sid=planted0000000000000000000000000000' http://127.0.0.1:8975/whoami
    
    anonymous role=anonymous
    
    status 200

    An anonymous session, worth nothing. This is the baseline for step 4.

  3. Step 3.

    Sign in carrying the planted value.

    curl -s -i -b 'sid=planted0000000000000000000000000000' http://127.0.0.1:8975/login-vulnerable | head -3
    
    HTTP/1.1 200 OK
    content-type: text/plain
    set-cookie: sid=planted0000000000000000000000000000; Path=/; HttpOnly; SameSite=Lax

    Same value, now attached to an account. The identifier survived the privilege change. That is the defect.

  4. Step 4.

    Spend it from a client that never saw the login.

    curl -s -w '\nstatus %{http_code}\n' -b 'sid=planted0000000000000000000000000000' http://127.0.0.1:8975/whoami
    
    alice role=user
    
    status 200

    The string that returned anonymous in step 2 returns alice. No password, nothing intercepted.

  5. Step 5.

    Now the case that passes a naive test. /login-rotate-only issues a fresh identifier and keeps the old record.

    curl -s -o /dev/null -b 'sid=planted1111111111111111111111111111' http://127.0.0.1:8975/start && curl -s -i -b 'sid=planted1111111111111111111111111111' http://127.0.0.1:8975/login-rotate-only | grep -Ei '^HTTP|^set-cookie'
    
    HTTP/1.1 200 OK
    set-cookie: sid=24a10c52017489d2594cd69d5326495b; Path=/; HttpOnly; SameSite=Lax

    Compare with the planted value and the assertion that the id changes after login passes. Spend the planted value anyway.

    curl -s -w '\nstatus %{http_code}\n' -b 'sid=planted1111111111111111111111111111' http://127.0.0.1:8975/whoami
    
    alice role=user
    
    status 200

    A new identifier was issued and the planted one still signs in as alice. Comparing the cookie before and after login is necessary and not sufficient. The second half is the replay.

  6. Step 6.

    Run the same sequence against the route that drops the old record.

    curl -s -o /dev/null -b 'sid=planted2222222222222222222222222222' http://127.0.0.1:8975/start && curl -s -D - -o /dev/null -b 'sid=planted2222222222222222222222222222' http://127.0.0.1:8975/login-fixed | grep -i '^set-cookie' && curl -s -w '\nplanted value: status %{http_code}\n' -b 'sid=planted2222222222222222222222222222' http://127.0.0.1:8975/whoami
    
    set-cookie: sid=1eb7e6a3e358d7e12abafc13f26e9b6b; Path=/; HttpOnly; SameSite=Lax
    Not signed in
    
    planted value: status 401

    401 on the planted value is the only passing result. Confirm the new identifier works, so it cannot be blamed on a bad login.

    curl -s -w '\nstatus %{http_code}\n' -b 'sid=1eb7e6a3e358d7e12abafc13f26e9b6b' http://127.0.0.1:8975/whoami
    
    alice role=user
    
    status 200
  7. Step 7.

    Repeat the question at the next privilege change. Raise the role on a session from the fixed route.

    curl -s -D - -b 'sid=1eb7e6a3e358d7e12abafc13f26e9b6b' http://127.0.0.1:8975/elevate | grep -Ei '^HTTP|^set-cookie|^role'
    
    HTTP/1.1 200 OK
    role now admin on 1eb7e6a3e358d7e12abafc13f26e9b6b

    There is no Set-Cookie line in that response. The role moved from user to admin on an identifier that existed before the change, so a value captured while the account was an ordinary user now opens an admin session. The login is fixed, the elevation is not.

    curl -s -b 'sid=1eb7e6a3e358d7e12abafc13f26e9b6b' http://127.0.0.1:8975/whoami
    
    alice role=admin

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The planted value is echoed back by the first request | The server adopts client-chosen identifiers | Report it. Every later defect on this page depends on it | | Set-Cookie after login repeats the planted value | The session was not regenerated | Report as session fixation, severity high | | A new Set-Cookie after login, planted value still returns the account | The identifier rotated and the old record survived | Report as session fixation. The before and after comparison hides it | | A new Set-Cookie and 401 on the planted value | The login regenerates and invalidates | Move to step 7 and repeat at the privilege change | | No Set-Cookie on a privilege change that raises the role | The elevated session reuses the identifier it had before | Report against the elevation handler, separately from login | | The first request rejects the planted value and issues its own | The server never adopts client identifiers | Note it. Fixation through a cookie is closed, check URL and header paths too |

Common mistakes

Sign: The test asserts that the session id after login differs from the one before, and passes.Cause: Step 5 passes that assertion on a server where the planted identifier still signs in as alice. Issuing a new value and invalidating the old one are two separate actions, and a rotation that skips the second is common after a framework upgrade. Replay the old value.
Sign: The check is run only at login and the report says the application regenerates its session id.Cause: Step 7 raises a session from user to admin with no Set-Cookie in the response. Any privilege change needs a new identifier, including elevation, a step-up second factor, and switching into another tenant or another account.
Sign: The tester cannot plant a value because the browser refuses to set the cookie.Cause: Sending a cookie is not the same as a browser accepting one. Plant with curl, as in step 1, which sends any value you name. A browser path is a separate question that depends on the domain, the path, and whether the application reads the identifier from a URL parameter.
Sign: The result differs between the staging environment and production.Cause: Regeneration happens in one place and the session store in another. With a shared store behind several nodes, deleting the old record locally leaves it readable on every other node, which reproduces step 5 in production and not in a single process test environment.

What to check next

FAQ

How to check if the session id changes after login?

Read Set-Cookie on the login response and compare it with the value the client sent. Step 3 shows them equal, which fails. A difference is necessary and not sufficient: in step 5 they differ and the old value still works. Replay it.

How to fix session fixation?

Two actions at every privilege change, not one: create a new identifier, and delete the record behind the old one. Most frameworks expose one call with a flag that decides whether the old record dies. Read the flag, then rerun step 5.

Is planting a session id an attack?

On your own build it is a test input like any other. Delivering a chosen identifier to another person's browser is an attack, and this procedure is not for that. Everything here runs against a local server.

Does this apply when the session is a JWT?

The question survives, the mechanism changes. Sign in, then send the pre-login token to a protected route. Anything but a refusal is the same defect in a different format.

What if the application takes the session id from the URL?

Then the plant needs no cookie and the risk is higher, because a link carries the value. Repeat step 1 with the identifier as a query parameter, and check whether the server accepts it and where it ends up logged.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny