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

// 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

  1. 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/form
    
    signed 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.

  2. 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 403 in the next step proves nothing, because a broken request also fails.

  3. 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.

  4. Step 4.

    Send the unprotected route the same forged body, with a cross-site Origin header 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/ledger
    
    sent 9000 to mallory [200]
    alice -> bob 10 via /transfer origin=none
    alice -> mallory 9000 via /transfer-legacy origin=http://127.0.0.1:8874

    The ledger is the point. The forged transfer completed, and the server logged the foreign Origin it arrived with and acted anyway. A header the application never reads is not a defence.

  5. 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 wrong

    A real cross-site submission from 127.0.0.1:8874 to localhost:8873. The browser attached the session cookie and the unprotected route moved money. The token check refused the identical request.

  6. Step 6.

    Log in again without SameSite=None, so the session cookie keeps the Lax default, 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 mallory

    Read those two lines together. SameSite=Lax stopped the post from 127.0.0.1 and let the post from localhost:8874 through, against the same unprotected route with the same cookie. A different port is a different origin and the same site, so SameSite never 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

Sign: The forged request returns 401, and the route is recorded as protected against cross-site requests.Cause: A 401 means no session reached the server, so the CSRF check was never exercised. Only a request that carries the victim's cookie tests anything. 403 is the answer that proves a token check ran.
Sign: The session cookie is SameSite=Lax, so cross-origin posts are treated as impossible.Cause: SameSite is about site, not origin. In step 6 the same Lax cookie was withheld from a page on 127.0.0.1:8874 and sent to a page on localhost:8874, and the unprotected transfer went through. Another port or a subdomain you do not control is inside the same site.
Sign: curl reproduces the attack, so the finding is filed as confirmed.Cause: curl attaches whatever jar you hand it and ignores SameSite entirely. It shows what the server accepts, which is step 3. Whether a browser would send the cookie is a separate question, and step 5 is where it gets answered.
Sign: The token is present in the page, so the form is treated as covered.Cause: A token that the server never compares is a hidden input. Step 3 is the only proof: post a token from a different session and require a rejection.

What to check next

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.

intermediate12 minpublished updated Maks Verny