How to test CSRF protection
Read the token out of the form, then post the same request twice: once with it and once without. curl -b jar.txt URL/transfer -d "csrf=$TOKEN&to=bob&amount=10" answers 200 and the same command with the token dropped answers 403. Then repeat the post from a second origin in a browser.
Why check this
This check runs against a service you own. Everything below uses a local target on your own machine, on two local origins, and nothing is aimed at a third party.
Run it before a release that adds a state-changing route, and after any change to the session cookie attributes, because the two defences interact. The failure it prevents is a POST that a page on another site makes on behalf of a signed-in user: a transfer, a password change, a delete. The user visits an unrelated page and the request goes out with their cookies attached.
Prerequisites
- A service you own. On a third party this is an attack, not a test.
- Node 22 or later. Save the file below as
session-demo.js./transferchecks a token,/transfer-legacychecks nothing, and the second listener on port 8874 stands in for the other site. - curl 8 or later, plus Chrome for steps 5 and 6. Browser results here are one capture on one machine.
- The OWASP cross-site request forgery prevention guidance for the defences the steps below are testing.
// session-demo.js - one local target for the cookie, session, remember-me and CSRF checks.
// Node 22, no dependencies. One process, two origins:
// the app you own http://localhost:8873
// an unrelated site http://127.0.0.1:8874
const http = require('node:http');
const crypto = require('node:crypto');
const USERS = { alice: 'correct-horse' };
const sessions = new Map(); // sid -> { user, born, dies, remembered, csrf }
const tokens = new Map(); // remember token -> user
const ledger = [];
const text = { 'content-type': 'text/plain' };
const html = { 'content-type': 'text/html' };
const id = () => crypto.randomBytes(8).toString('hex');
const jar = (req, name) => {
const hit = (req.headers.cookie || '').split(';').map((c) => c.trim().split('='))
.find(([k]) => k === name);
return hit ? hit.slice(1).join('=') : null;
};
const body = (req) => new Promise((r) => {
let s = ''; req.on('data', (d) => { s += d; }); req.on('end', () => r(new URLSearchParams(s)));
});
const live = (sid) => {
const s = sessions.get(sid);
if (!s) return null;
if (Date.now() > s.dies) { sessions.delete(sid); return null; } // server-side clock
return s;
};
const app = async (req, res) => {
const u = new URL(req.url, 'http://localhost:8873');
const p = u.pathname, q = u.searchParams;
const hour = 3600e3;
// ---- four cookie lifetimes, no session involved --------------------------
if (p === '/cookie/session')
return res.writeHead(200, { ...text, 'set-cookie': 'shape_session=1; Path=/' }).end('session\n');
if (p === '/cookie/expires')
return res.writeHead(200, { ...text, 'set-cookie': `shape_expires=1; Path=/; Expires=${new Date(Date.now() + hour).toUTCString()}` }).end('expires\n');
if (p === '/cookie/maxage')
return res.writeHead(200, { ...text, 'set-cookie': 'shape_maxage=1; Path=/; Max-Age=3600' }).end('max-age\n');
if (p === '/cookie/both') // Expires already past, Max-Age an hour ahead
return res.writeHead(200, { ...text, 'set-cookie': `shape_both=1; Path=/; Expires=${new Date(Date.now() - hour).toUTCString()}; Max-Age=3600` }).end('both\n');
if (p === '/cookie/long') // two years, past the 400-day cap in RFC 6265bis
return res.writeHead(200, { ...text, 'set-cookie': 'shape_long=1; Path=/; Max-Age=63072000' }).end('long\n');
// ---- login. ?ttl=<seconds> server session, ?persist=1 persistent sid cookie
// ?policy=evict single session per user, ?cookie=none SameSite=None; Secure
if (p === '/login' && req.method === 'POST') {
const f = await body(req);
const user = f.get('username') || '';
if (USERS[user] !== f.get('password')) return res.writeHead(401, text).end('bad credentials\n');
if (q.get('policy') === 'evict') for (const [k, v] of sessions) if (v.user === user) sessions.delete(k);
const ttl = Number(q.get('ttl') || 600) * 1000;
const remember = f.get('remember') === 'on';
const sid = id();
sessions.set(sid, { user, born: Date.now(), dies: Date.now() + ttl, remembered: remember, csrf: id() });
const site = q.get('cookie') === 'none' ? '; Secure; SameSite=None' : '; SameSite=Lax';
const set = [`sid=${sid}; Path=/; HttpOnly${site}${q.get('persist') ? '; Max-Age=3600' : ''}`];
if (remember) {
const t = id(); tokens.set(t, user);
set.push(`remember=${t}; Path=/; HttpOnly${site}; Max-Age=1209600`);
}
return res.writeHead(200, { ...text, 'set-cookie': set }).end(`signed in ${user} sid=${sid} ttl=${ttl / 1000}s\n`);
}
if (p === '/account') {
const sid = jar(req, 'sid'), s = live(sid);
if (s) return res.writeHead(200, text).end(`signed in as ${s.user} sid=${sid}\n`);
const t = jar(req, 'remember');
if (t && tokens.has(t)) { // the remember-me path
const user = tokens.get(t), fresh = id();
sessions.set(fresh, { user, born: Date.now(), dies: Date.now() + 600e3, remembered: true, csrf: id() });
return res.writeHead(200, { ...text, 'set-cookie': `sid=${fresh}; Path=/; HttpOnly; SameSite=Lax` })
.end(`restored from remember cookie as ${user} sid=${fresh}\n`);
}
return res.writeHead(401, text).end('not signed in\n');
}
if (p === '/sessions') {
const s = live(jar(req, 'sid'));
if (!s) return res.writeHead(401, text).end('not signed in\n');
const rows = [...sessions].filter(([, v]) => v.user === s.user && v.dies > Date.now())
.map(([k, v]) => `${k} age=${Math.round((Date.now() - v.born) / 1000)}s remembered=${v.remembered}`);
return res.writeHead(200, text).end(`${rows.join('\n')}\n`);
}
// ?scope=one drops this session only, ?scope=all drops every session and token
if (p === '/logout' && req.method === 'POST') {
const sid = jar(req, 'sid'), s = sessions.get(sid), scope = q.get('scope') || 'one';
if (scope === 'all' && s) {
for (const [k, v] of sessions) if (v.user === s.user) sessions.delete(k);
for (const [k, v] of tokens) if (v === s.user) tokens.delete(k);
} else sessions.delete(sid);
return res.writeHead(200, { ...text, 'set-cookie': 'sid=; Path=/; Max-Age=0' }).end(`logged out scope=${scope}\n`);
}
// ---- CSRF: /transfer checks the token, /transfer-legacy checks nothing ----
if (p === '/form') {
const s = live(jar(req, 'sid'));
if (!s) return res.writeHead(401, text).end('not signed in\n');
return res.writeHead(200, html).end(`<form method="post" action="/transfer">
<input type="hidden" name="csrf" value="${s.csrf}">
<input name="to"><input name="amount"><button>Send</button></form>`);
}
if ((p === '/transfer' || p === '/transfer-legacy') && req.method === 'POST') {
const s = live(jar(req, 'sid'));
if (!s) return res.writeHead(401, text).end('no session cookie, nothing to forge\n');
const f = await body(req);
if (p === '/transfer' && f.get('csrf') !== s.csrf)
return res.writeHead(403, text).end('csrf token missing or wrong\n');
ledger.push(`${s.user} -> ${f.get('to')} ${f.get('amount')} via ${p} origin=${req.headers.origin || 'none'}`);
return res.writeHead(200, text).end(`sent ${f.get('amount')} to ${f.get('to')}\n`);
}
if (p === '/ledger') return res.writeHead(200, text).end(`${ledger.join('\n')}\n`);
return res.writeHead(404, text).end('not found\n');
};
// The second origin. It is the same machine and a different site: cookies set on
// localhost are cross-site for a page served from 127.0.0.1.
const other = (req, res) => {
const path = new URL(req.url, 'http://127.0.0.1:8874').searchParams.get('path') || '/transfer-legacy';
res.writeHead(200, html).end(`<!doctype html><title>Unrelated site</title><p>An article.</p>
<form id="f" method="post" action="http://localhost:8873${path}">
<input type="hidden" name="to" value="mallory"><input type="hidden" name="amount" value="9000"></form>
<script>document.getElementById('f').submit()</script>`);
};
http.createServer(app).listen(8873, () => console.log('app http://localhost:8873'));
http.createServer(other).listen(8874, () => console.log('other http://127.0.0.1:8874'));
Confirm both ports have no listener, start the server, then read its real process id so you can stop that one process at the end.
netstat -ano | grep -E ':887[34] ' ; node session-demo.js &
Steps
- Step 1.
Sign in and read the form, so you can see the token the application expects.
curl -s -c app.txt http://localhost:8873/login -d 'username=alice&password=correct-horse' && curl -s -b app.txt http://localhost:8873/formsigned in alice sid=ee61e966bbf4048d ttl=600s <form method="post" action="/transfer"> <input type="hidden" name="csrf" value="872e809fc8415970"> <input name="to"><input name="amount"><button>Send</button></form>The token sits in a hidden input. A different session gets a different value, which is the property the rest of the page tests.
- Step 2.
Pull the token out into a shell variable and submit the request the way the application does.
TOKEN=$(curl -s -b app.txt http://localhost:8873/form | grep -o 'name="csrf" value="[0-9a-f]*"' | cut -d'"' -f4) && curl -s -b app.txt -w ' [%{http_code}]\n' http://localhost:8873/transfer -d "csrf=$TOKEN&to=bob&amount=10"sent 10 to bob [200]This is the control. Without a passing positive case, a
403in the next step proves nothing, because a broken request also fails. - Step 3.
Send the same request with the token dropped, and with the token of a different session.
curl -s -b app.txt -w ' [%{http_code}]\n' http://localhost:8873/transfer -d 'to=mallory&amount=9000' && curl -s -c other.txt http://localhost:8873/login -d 'username=alice&password=correct-horse' > /dev/null && OTHER=$(curl -s -b other.txt http://localhost:8873/form | grep -o 'name="csrf" value="[0-9a-f]*"' | cut -d'"' -f4) && curl -s -b app.txt -w ' [%{http_code}]\n' http://localhost:8873/transfer -d "csrf=$OTHER&to=mallory&amount=9000"csrf token missing or wrong [403] csrf token missing or wrong [403]Both rejections matter. The first shows the token is required, the second that it is bound to the session. A service that accepts any well-formed token counts characters instead of authorising.
- Step 4.
Send the unprotected route the same forged body, with a cross-site
Originheader attached by hand.curl -s -b app.txt -H 'Origin: http://127.0.0.1:8874' -w ' [%{http_code}]\n' http://localhost:8873/transfer-legacy -d 'to=mallory&amount=9000' && curl -s http://localhost:8873/ledgersent 9000 to mallory [200] alice -> bob 10 via /transfer origin=none alice -> mallory 9000 via /transfer-legacy origin=http://127.0.0.1:8874The ledger is the point. The forged transfer completed, and the server logged the foreign
Originit arrived with and acted anyway. A header the application never reads is not a defence. - Step 5.
Move to a browser, because curl decides for itself which cookies to attach and a browser does not. Log in so the cookie is sent cross-site, then load the other origin, whose page submits a form at your app.
node -e "import('./scripts/browser/session.mjs').then(async ({ open }) => { const s = await open(); await s.goto('http://localhost:8873/account'); await s.page.evaluate(() => fetch('/login?cookie=none', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'username=alice&password=correct-horse' })); const { cookies } = await s.cdp.send('Network.getAllCookies'); for (const c of cookies) console.log('cookie', c.name, 'sameSite=' + c.sameSite, 'secure=' + c.secure); for (const path of ['/transfer-legacy', '/transfer']) { await s.goto('http://127.0.0.1:8874/?path=' + path); await new Promise((r) => setTimeout(r, 700)); console.log(path, '->', (await s.page.evaluate(() => document.body.innerText)).trim()); } await s.close(); })"cookie sid sameSite=None secure=true /transfer-legacy -> sent 9000 to mallory /transfer -> csrf token missing or wrongA real cross-site submission from
127.0.0.1:8874tolocalhost:8873. The browser attached the session cookie and the unprotected route moved money. The token check refused the identical request. - Step 6.
Log in again without
SameSite=None, so the session cookie keeps theLaxdefault, and repeat the attack from both a different site and a different port.node -e "import('./scripts/browser/session.mjs').then(async ({ open }) => { const s = await open(); await s.goto('http://localhost:8873/account'); await s.page.evaluate(() => fetch('/login', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'username=alice&password=correct-horse' })); for (const from of ['http://127.0.0.1:8874', 'http://localhost:8874']) { await s.goto(from + '/?path=/transfer-legacy'); await new Promise((r) => setTimeout(r, 700)); console.log(from, '->', (await s.page.evaluate(() => document.body.innerText)).trim()); } await s.close(); })"http://127.0.0.1:8874 -> no session cookie, nothing to forge http://localhost:8874 -> sent 9000 to malloryRead those two lines together.
SameSite=Laxstopped the post from127.0.0.1and let the post fromlocalhost:8874through, against the same unprotected route with the same cookie. A different port is a different origin and the same site, soSameSitenever applies to it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 200 with the token, 403 without it | The token is required | Continue to the cross-session case in step 3 |
| 403 in both halves of step 3 | The token is bound to the session | The check is doing its job |
| Another session's token accepted | The server validates the shape, not the owner | Report it. A token anyone can mint is decoration |
| Step 5 moves money on any route | That route has no token check | Report the route, not the application |
| Step 6 blocks 127.0.0.1 and allows localhost:8874 | SameSite is the only defence and it does not cover same-site origins | Add a token check. Another port, or a subdomain, is not covered |
| 401 instead of 403 on the forged post | The cookie never arrived | Check SameSite before concluding the route is protected |
Common mistakes
What to check next
- How to check SameSite cookie attribute: the attribute step 6 turns on and off, read from the wire.
- How to check if cookies are secure and HttpOnly: the other two attributes on the session cookie a forged request depends on.
- How to test login with curl: the login and jar handling every step here starts from.
- How to test concurrent sessions: how to hold two sessions at once, which step 3 needs.
- How to check cookie expiry: how long the cookie a forged request rides on stays on disk.
FAQ
How to check if a CSRF token is valid?
Post it and read the status. Step 2 posts the session's own token and gets 200. Step 3 posts a token minted for a different session and gets 403. A service that accepts the second one is not validating ownership.
How to check a CSRF token in the browser?
Open DevTools, Elements tab, and search the form for a hidden input named csrf, authenticity_token or _token. In the Network tab, click the submitted request and read the Payload panel, or the Headers panel if the token travels as X-CSRF-Token.
How to extract and use a CSRF token with curl?
Step 2 does it in one line: fetch the form, pull the value with grep -o and cut, then pass it in -d with the same jar the form was fetched with.
Is checking the referrer enough to protect against CSRF?
No. A request can arrive with no Referer at all, and the server then has to choose between rejecting ordinary traffic and allowing the forgery. Step 4 shows a route reading neither header. Use a token, and treat origin checks as a second layer.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.7977.76
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
intermediate12 minpublished updated Maks Verny