Node.js Development

Understanding and Implementing CSRF Tokens in PHP Forms

By the Domain India teamPublished 9 min read
Knowledge base article
Contents (9 sections)

Cross-Site Request Forgery (CSRF) tricks a logged-in user's browser into sending a request your site did not ask for: changing an email address, deleting a record, or placing an order. A CSRF token stops it by making every state-changing form carry a secret value that another website cannot know. This guide shows how the attack works and gives working PHP 8 code you can drop into a plain PHP site.

Key takeaways

Generate one random token per session with random_bytes(), store it in $_SESSION, put it in a hidden field in every POST form, and compare it with hash_equals() before you change anything. Set your session cookie to SameSite=Lax, Secure and HttpOnly, never change data on a GET request, and send the token in an X-CSRF-Token header for JavaScript requests. If you use a framework or WordPress, use its built-in protection instead of writing your own.

1. How a CSRF attack works

Browsers attach your cookies to every request sent to your site, even when another website starts that request.

  1. You log in
    to example.com. The browser stores a session cookie.
  2. You visit another page
    in another tab: a forum post, an email link, an ad.
  3. That page submits a hidden form
    to https://example.com/account/email with an attacker's email address.
  4. Your browser adds your session cookie
    automatically, so your site sees a valid, logged-in request and changes the email.

The attacker never sees your cookie or the response. They only need the request to happen. That is why the defence has to be something the attacker's page cannot include: a secret token that only your own pages know.

2. What a CSRF token is

A CSRF token is a long random value that your server:

  • creates and stores in the user's session;
  • prints into each form (or page) it serves;
  • expects back with every POST, PUT, PATCH or DELETE request;
  • compares with the stored value before doing anything.

Another website can make the browser send a request, but it cannot read your pages, so it cannot learn the token. A request without the right token is rejected.

One token per session is the approach OWASP recommends for most sites. A new token on every request adds little protection and breaks the Back button and multiple open tabs.

3. A reusable CSRF helper for PHP 8

Save this as csrf.php and include it at the top of every page that shows or handles a form.

php
<?php
declare(strict_types=1);

function start_secure_session(): void
{
    if (session_status() === PHP_SESSION_ACTIVE) {
        return;
    }
    session_set_cookie_params([
        'lifetime' => 0,
        'path'     => '/',
        'secure'   => true,   // cookie only over HTTPS
        'httponly' => true,   // JavaScript cannot read it
        'samesite' => 'Lax',  // not sent on cross-site POSTs
    ]);
    session_start();
}

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

function csrf_field(): string
{
    return '<input type="hidden" name="csrf_token" value="'
        . htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') . '">';
}

function csrf_verify(): void
{
    $sent = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
    $expected = $_SESSION['csrf_token'] ?? '';

    if (!is_string($sent) || $expected === '' || !hash_equals($expected, $sent)) {
        http_response_code(403);
        exit('This form has expired. Please reload the page and try again.');
    }
}

What each part does:

  • random_bytes(32) is a cryptographically secure generator. Never use rand(), mt_rand(), uniqid() or md5(time()) for tokens.
  • hash_equals() compares in constant time, so the check does not leak the token through timing.
  • htmlspecialchars() escapes the value when it is printed into HTML.
  • is_string() rejects a crafted request that sends csrf_token[] as an array.

4. A complete contact form with CSRF protection

php
<?php
declare(strict_types=1);
require __DIR__ . '/csrf.php';
start_secure_session();

$errors = [];
$sent = false;
$name = $email = $message = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    csrf_verify();

    $name    = trim((string) ($_POST['name'] ?? ''));
    $email   = trim((string) ($_POST['email'] ?? ''));
    $message = trim((string) ($_POST['message'] ?? ''));

    if ($name === '')                                   { $errors[] = 'Please enter your name.'; }
    if (!filter_var($email, FILTER_VALIDATE_EMAIL))     { $errors[] = 'Please enter a valid email.'; }
    if ($message === '' || mb_strlen($message) > 5000)  { $errors[] = 'Please enter a message.'; }

    if (!$errors) {
        // Save to your database or send the email here.
        $sent = true;
    }
}

function e(string $v): string { return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); }
?>
<?php if ($sent): ?>
  <p>Thank you. We will reply soon.</p>
<?php else: ?>
  <?php foreach ($errors as $err): ?><p class="error"><?= e($err) ?></p><?php endforeach; ?>
  <form method="post" action="">
    <?= csrf_field() ?>
    <label for="name">Name</label>
    <input id="name" name="name" value="<?= e($name) ?>" required>
    <label for="email">Email</label>
    <input id="email" name="email" type="email" value="<?= e($email) ?>" required>
    <label for="message">Message</label>
    <textarea id="message" name="message" required><?= e($message) ?></textarea>
    <button type="submit">Send</button>
  </form>
<?php endif; ?>

The token check runs first, before any validation or database work. Every value printed back into the page goes through e(), so the form is not open to cross-site scripting, which would defeat CSRF protection entirely.

5. CSRF tokens with JavaScript (fetch)

For AJAX requests, print the token once into the page head as a meta tag, then send it as a header. The helper above already accepts the X-CSRF-Token header.

php
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') ?>">
javascript
const token = document.querySelector('meta[name="csrf-token"]').content;

const response = await fetch('/api/save-note.php', {
  method: 'POST',
  credentials: 'same-origin',
  headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
  body: JSON.stringify({ note: 'Hello' }),
});

This works without jQuery. Keep the token out of URLs and out of localStorage: URLs end up in logs and browser history.

6. Extra layers that make CSRF harder

A token is the main defence. These layers catch mistakes and older browsers.

LayerWhat to doWhy it helps
SameSite cookieSet SameSite=Lax (or Strict for admin areas) on the session cookieThe browser does not send the cookie on most cross-site POSTs
Safe GET requestsNever change data on GET; use POST for delete, logout and updateLinks and image tags can trigger GETs from anywhere
Origin checkReject POSTs whose Origin or Sec-Fetch-Site header shows another siteBlocks cross-site requests even if a token check is missed
Session renewalCall session_regenerate_id(true) and clear the token at login and logoutStops a token or session from carrying over between users
HTTPS everywhereServe the whole site over HTTPS with Secure cookiesTokens and cookies cannot be read in transit

A simple origin check, added at the start of csrf_verify():

php
$site = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? null;
if ($site !== null && !in_array($site, ['same-origin', 'none'], true)) {
    http_response_code(403);
    exit('Cross-site request refused.');
}

Do not rely on SameSite alone. Browsers differ in their defaults, and a subdomain you do not control counts as "same site".

7. Frameworks and WordPress already do this

If your site is built on a framework or CMS, use its protection and do not add a second, home-made token.

Laravel
Put the @csrf directive in every Blade form. The framework checks the token on POST, PUT, PATCH and DELETE.
Symfony
Symfony Forms add and check a token automatically; for custom forms use csrf_token() and isCsrfTokenValid().
CodeIgniter 4
Enable the csrf filter and add csrf_field() to forms.
WordPress
Use nonces: wp_nonce_field() in the form and check_admin_referer() or wp_verify_nonce() when handling it.

8. Common mistakes

  • Checking the token only on some forms. Every state-changing request needs it, including delete buttons and AJAX endpoints.
  • Using == instead of hash_equals(). == is not constant-time, and PHP's loose comparison can behave unexpectedly with odd input.
  • Leaving an XSS hole. Any script injected into your pages can read the token. Escape all output; see Preventing XSS in PHP and Node.js.
  • Printing the token in a GET URL such as delete.php?id=5&token=…. Use a small POST form for actions instead.
  • Forgetting the session. If session_start() has not run, $_SESSION is empty and every check fails.

Test it: open your form, delete the hidden field with the browser's developer tools, and submit. You should get the 403 message. Security testers such as OWASP ZAP and Burp Suite can also check every form for missing tokens.

9. Running PHP forms on Domain India hosting

PHP sessions work normally on all Domain India shared hosting, so the code above runs as it is. On our cPanel servers you can choose the PHP version per account, and new accounts start on PHP 8.3 (measured on our servers, 20 September 2026). The code needs PHP 7.3 or newer and is written for PHP 8. Free SSL is included with our hosting plans, which the Secure cookie flag needs.

Some PHP functions are disabled on shared hosting for security. The CSRF code does not use any of them, but see PHP disabled functions on shared hosting before you add libraries. If your form sends email, read PHP sendmail settings.

Domain India list prices on 19 September 2026, excluding 18% GST: cPanel Starter is ₹125 a month and DirectAdmin Starter is ₹100 a month.

What is a CSRF token in PHP?

It is a long random value, usually created with random_bytes(), stored in the user's session and included as a hidden field in each form. When the form is submitted, the server compares the submitted value with the stored one using hash_equals() and rejects the request if they do not match.

Do I need a new CSRF token for every form submission?

No. One token per session is enough for most websites and is what OWASP recommends. Per-request tokens add little protection and break the Back button and multiple open tabs. Do create a fresh token when the user logs in or out.

Is SameSite=Lax enough to stop CSRF without tokens?

Not on its own. SameSite cookies block many cross-site requests, but browser defaults differ and requests from your own subdomains count as same-site. Use SameSite as an extra layer alongside CSRF tokens.

Should GET requests have CSRF tokens?

GET requests should not change anything, so they do not need tokens. Move any action that changes data, such as delete or logout, to a POST request with a token.

How do I send a CSRF token with fetch or AJAX?

Print the token into a meta tag in the page, read it with JavaScript, and send it in a custom header such as X-CSRF-Token. On the server, read it from $_SERVER['HTTP_X_CSRF_TOKEN'] and compare it with hash_equals().

Does WordPress have CSRF protection?

Yes. WordPress uses nonces. Add wp_nonce_field() to your form and check it with check_admin_referer() or wp_verify_nonce() when you process the request.

Why do I get "This form has expired" after leaving a page open?

The session probably expired, so the stored token no longer exists. Reloading the page creates a new session and a new token. You can make sessions last longer, but keep them reasonably short for security.

Ready to build? Host your PHP site on cPanel hosting or DirectAdmin hosting, and read Preventing SQL injection in PHP and Node.js next.

Host your PHP application

Choose your PHP version per account, with free SSL for secure session cookies.

See cPanel plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app