Cross-Site Request Forgery is the bug your AI assistant keeps forgetting to protect against — partly because it's the absence of code, not the presence of bad code, and partly because it assumes "the framework handles it" even when the framework doesn't. This page is the short, practical version: what to do, what to type, what to skip.

What CSRF actually is (60 seconds)

You're logged into your bank. Another tab opens an attacker's blog. That blog contains:

<form action="https://yourbank.com/transfer" method="post">
  <input type="hidden" name="to" value="attacker-account">
  <input type="hidden" name="amount" value="9999">
</form>
<script>document.forms[0].submit();</script>

The browser sends the form to yourbank.com with your session cookie attached — because that's what browsers do with cookies. If /transfer doesn't verify that the request actually came from your tab, the transfer goes through.

The protection is a CSRF token: a random value that only code running on your origin can read (because of the same-origin policy). Every form on your site emits it, every handler checks it. Attacker's page can't read it, can't include it, transfer fails.

The canonical pattern (plain PHP)

Four pieces. Generate the token at session start. Render it on every state-changing form. Check it at the top of every state-changing handler. Reject the request if it's missing or wrong.

1. Bootstrap — start the session and put a token in it.

<?php
session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

One token per session, generated once. Don't regenerate on every request — that breaks the browser back button on form errors. random_bytes(32) gives you 64 hex chars of real CSPRNG output. Don't use md5(uniqid()) or any of the other guessable sources.

2. Render it on the form.

<form method="post" action="/transfer.php">
  <input type="hidden" name="csrf_token"
         value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
  <label>To: <input name="to"></label>
  <label>Amount: <input name="amount"></label>
  <button>Transfer</button>
</form>

3. Check it on the handler.

<?php
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (empty($_POST['csrf_token'])
        || !hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'])) {
        http_response_code(403);
        die('Invalid CSRF token.');
    }

    // ... process the transfer ...
}

Use hash_equals(), not ===. hash_equals() is a constant-time comparison — it takes the same amount of CPU whether the strings match in the first byte or the last. === short-circuits on the first different character, which is theoretically detectable via timing. Probably not exploitable in your specific case, but the cost of doing it right is zero.

That's it. Four lines of code, three places. Every form that mutates state gets the hidden input; every handler that mutates state checks it.

The UserSpice version (one-line helpers)

If you're on UserSpice, the framework ships the helpers — the four lines above collapse into two function calls:

// Form
<form method="post">
  <?= tokenHere() ?>  <span class="c-com">// emits <input type="hidden" name="csrf" value="...">
  ...
</form>

// Handler
if (Input::exists()) {
    if (!Token::check(Input::get('csrf'))) {
        usError('Invalid security token. Please refresh and try again.');
        Redirect::to(Server::get('PHP_SELF'));
    }
    // ... process ...
}

Field name watch: UserSpice's tokenHere() emits name="csrf", not name="csrf_token". The handler must check Input::get('csrf'), not Input::get('csrf_token'). The mismatch is the #1 cause of "every form silently fails" on a fresh UserSpice page.

AJAX endpoints

The browser doesn't fill in a hidden form input on an XHR / fetch call — you have to read the token from the page and send it as a header or as a JSON field.

Reading the token on the page:

<meta name="csrf-token" content="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">

Sending it on every request:

const token = document.querySelector('meta[name="csrf-token"]').content;

fetch('/parsers/save_note.php', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': token,
  },
  body: JSON.stringify({ note: 'hello' }),
});

Checking it on the handler:

$incoming = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $incoming)) {
    http_response_code(403);
    echo json_encode(['ok' => false, 'error' => 'Invalid CSRF token']);
    exit;
}

Same idea, different transport. The header name is yours to pick — X-CSRF-Token is conventional but not magic.

One important detail on the UserSpice / AJAX side: AJAX endpoints in UserSpice live in parsers/ folders, and a parser is a separate request. It doesn't inherit auth state or CSRF checks from the page that called it. Re-do the CSRF check at the top of the parser, before any DB writes.

Five things people get wrong

  1. Regenerating the token on every request. Breaks the back button — user submits a form, gets an error, hits back, re-submits, gets "invalid token" because the token in the form is now stale. Generate once per session.
  2. Comparing with == or === instead of hash_equals(). Constant-time comparison is the right choice for any secret value. There's no downside.
  3. Mismatched field names. tokenHere() writes name="csrf"; $_POST['csrf_token'] is therefore null; every check fails silently. On vanilla PHP, pick a convention and grep your codebase to confirm both ends agree.
  4. Skipping the check on AJAX endpoints. AJAX is the request type attackers most love to forge, because it doesn't show up in the user's URL bar. Every state-changing AJAX endpoint needs the check.
  5. Putting the token in a GET parameter. Tokens belong in form bodies and headers, not URLs. URLs end up in browser history, server logs, referer headers, and analytics. Anywhere the URL leaks, the token leaks.

What about checking the Referer header? SameSite cookies?

Two related questions that come up:

"Why not just check the Referer header?" Some people on the same network as you have privacy-focused browsers that strip the Referer header on outbound requests. Sometimes corporate proxies do it. Sometimes browser extensions do it. A CSRF defense that fails open when the header is missing fails open for those users; a defense that fails closed breaks legitimate use. The token approach doesn't have this problem — the token is either present and matches, or it isn't.

"Doesn't SameSite=Lax on session cookies already solve this?" Partially. A session cookie with SameSite=Lax isn't sent on cross-site POST requests, which kills the attack pattern at the top of this page. Browsers added this default a few years ago. But: SameSite is a defense-in-depth layer, not a replacement for tokens. Reasons:

  • SameSite=Lax still allows top-level GET navigation — if you have state-changing GET endpoints (you shouldn't, but they exist), they're still exposed.
  • Older browsers and some embedded webviews don't enforce SameSite correctly. The long tail of "stable enough to use" includes versions that don't.
  • Subdomain takeovers can host attack pages on a "same-site" origin from the cookie's perspective.

Use both. SameSite=Lax (or Strict for state-changing-only flows) on session cookies, plus token checks on state-changing handlers. The token check is the real defense; SameSite is the second wall.

Tools that catch missing CSRF

  • /userspice-audit (Rule 03) — walks every PHP file and flags forms with method="post" that lack a CSRF field, and handlers that process POST data without calling Token::check.
  • Semgrep custom rules — both the missing-token-on-form and missing-check-on-handler patterns are matchable. The Security Scanner ships rules for both.
  • OWASP ZAP — runtime CSRF detection by submitting state-changing requests without a token and watching for 200 OK responses.
  • The eyeball test — grep your project for method="post" and confirm every match has a name="csrf" hidden input near it. Then grep for the handlers and confirm each one calls the check.

The static tools have a known blind spot: when the form is rendered in one file and the handler is in another, the connection between "this form should have a token" and "this handler should check the token" is implicit. The audit skill follows the form's action attribute to bridge them; raw Semgrep doesn't. If you're scanning a complex codebase, a clean automated report on this rule still warrants a manual spot-check.

Want this retrofitted across your project?

Adding CSRF to one form is a five-minute job. Adding it to a hundred forms across an existing AI-generated codebase is a week of mechanical grep-and-replace. If you'd rather not, paste a repo URL below and we'll do the audit + retrofit.

Need someone to retrofit CSRF across an existing project? Send the repo and we'll do it.

We reply within 1–2 business days.