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
- Node 22 or later. Save the file below as
session-fixation.js. Three login routes put both defects and the fix in one run./elevateraises the role without touching the identifier. - curl 8 or later.
-b 'sid=value'sends a cookie without a jar, which is how a value is planted. - The OWASP session management guidance, which requires renewal after any privilege level change, not only at login.
// 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
- Step 1.
Plant an identifier of your choosing.
curl -s -i -b 'sid=planted0000000000000000000000000000' http://127.0.0.1:8975/start | head -3HTTP/1.1 200 OK content-type: text/plain set-cookie: sid=planted0000000000000000000000000000; Path=/; HttpOnly; SameSite=LaxThe 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.
- 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/whoamianonymous role=anonymous status 200An anonymous session, worth nothing. This is the baseline for step 4.
- Step 3.
Sign in carrying the planted value.
curl -s -i -b 'sid=planted0000000000000000000000000000' http://127.0.0.1:8975/login-vulnerable | head -3HTTP/1.1 200 OK content-type: text/plain set-cookie: sid=planted0000000000000000000000000000; Path=/; HttpOnly; SameSite=LaxSame value, now attached to an account. The identifier survived the privilege change. That is the defect.
- 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/whoamialice role=user status 200The string that returned
anonymousin step 2 returnsalice. No password, nothing intercepted. - Step 5.
Now the case that passes a naive test.
/login-rotate-onlyissues 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=LaxCompare 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/whoamialice role=user status 200A 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.
- 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/whoamiset-cookie: sid=1eb7e6a3e358d7e12abafc13f26e9b6b; Path=/; HttpOnly; SameSite=Lax Not signed in planted value: status 401401on 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/whoamialice role=user status 200 - 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 1eb7e6a3e358d7e12abafc13f26e9b6bThere is no
Set-Cookieline 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/whoamialice 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
What to check next
- How to check session id: whether the rotated identifier is worth anything once in place.
- How to check session timeout: how long a planted value lasts when nobody invalidates it.
- How to check if session expires after logout: the same replay method at the explicit end of a session.
- How to check if cookies are secure and HttpOnly: the flags that decide how a value reaches a browser.
- How to test login with curl: the cookie handling behind
-b, and why a jar would hide step 4.
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.
Related on this site
intermediate10 minpublished updated Maks Verny