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.
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.
- You log into
example.com. The browser stores a session cookie. - You visit another pagein another tab: a forum post, an email link, an ad.
- That page submits a hidden formto
https://example.com/account/emailwith an attacker's email address. - Your browser adds your session cookieautomatically, 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
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 userand(),mt_rand(),uniqid()ormd5(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 sendscsrf_token[]as an array.
4. A complete contact form with CSRF protection
<?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.
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') ?>">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.
| Layer | What to do | Why it helps |
|---|---|---|
| SameSite cookie | Set SameSite=Lax (or Strict for admin areas) on the session cookie | The browser does not send the cookie on most cross-site POSTs |
| Safe GET requests | Never change data on GET; use POST for delete, logout and update | Links and image tags can trigger GETs from anywhere |
| Origin check | Reject POSTs whose Origin or Sec-Fetch-Site header shows another site | Blocks cross-site requests even if a token check is missed |
| Session renewal | Call session_regenerate_id(true) and clear the token at login and logout | Stops a token or session from carrying over between users |
| HTTPS everywhere | Serve the whole site over HTTPS with Secure cookies | Tokens and cookies cannot be read in transit |
A simple origin check, added at the start of csrf_verify():
$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.
@csrf directive in every Blade form. The framework checks the token on POST, PUT, PATCH and DELETE.csrf_token() and isCsrfTokenValid().csrf filter and add csrf_field() to forms.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 ofhash_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,$_SESSIONis 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.
Choose your PHP version per account, with free SSL for secure session cookies.
See cPanel plans