Educational Content: This article is written for educational purposes to help developers and cybersecurity students understand software concepts. Always follow ethical guidelines and applicable laws.
There is a category of web attack that works without the victim doing anything suspicious. No clicking a strange link. No downloading a file. No entering credentials somewhere fake. The victim just visits a webpage while logged into another site — and their account silently performs an action they never intended.
That attack is called Cross-Site Request Forgery, almost always abbreviated to CSRF. It has been in the OWASP Top 10 list of critical web vulnerabilities for years, and it keeps appearing in real applications because the concept is subtle enough that many developers don't think about it until they've already shipped something vulnerable.
This post explains exactly how CSRF works, why it's effective, and how to implement proper protection in PHP — with complete, working code you can use directly in your projects.
The Core Idea Behind CSRF
To understand CSRF, you need to understand one important thing about how browsers handle cookies: when a browser makes a request to a domain, it automatically attaches any cookies it has stored for that domain — even if the request originates from a completely different website.
That's the behaviour that CSRF exploits. Here's a simple scenario to make it concrete.
You're logged into your bank's website. Your bank stores your session in a cookie. You open a new tab and visit a random website. That website contains an invisible image tag — or a form that auto-submits — pointing at your bank's transfer endpoint:
<!-- On the attacker's site -->
<img src="https://yourbank.com/transfer?to=attacker&amount=50000"
style="display:none">
Your browser sees an image request. It tries to load it. Because you're logged into yourbank.com, your browser automatically attaches your session cookie to the request. The bank's server receives a perfectly authenticated request — your session is valid, the request looks legitimate — and processes the transfer.
You never clicked anything. You never saw anything happen. Your browser did it on your behalf, automatically, because of a request embedded on a page you visited.
This is CSRF. The request is forged — it didn't originate from your genuine intention — and it crosses sites — from the attacker's domain to your bank's domain, carrying your credentials.
What Attackers Can Actually Do With CSRF
The transfer example sounds dramatic, but CSRF doesn't require a banking application to be damaging. Any application where authenticated users perform state-changing actions — submitting forms, updating settings, deleting data, sending messages — is potentially vulnerable if it doesn't implement CSRF protection.
Real-world CSRF attacks have been used to:
- Change a user's email address or password on a compromised account, locking them out
- Add an attacker-controlled admin account to a content management system
- Delete content or data on behalf of the logged-in user
- Make purchases or send payments in applications that trust session-authenticated requests
- Post content, send messages, or perform social actions on behalf of users
Any endpoint that changes server-side state and relies solely on session cookies for authentication is a candidate. In applications that don't implement any CSRF protection at all, an attacker just needs the victim to visit a page while logged in — which is something they can engineer through a simple phishing link or a compromised advertising network.
Why Cookies Alone Are Not Sufficient Authentication
This is the fundamental insight that explains why CSRF protection is necessary. A session cookie proves that a browser is authenticated — it does not prove that the user intentionally initiated this specific request.
Think about the difference between those two things. When a user clicks a button on your application, fills out a form, and submits it — they intended that action. When an attacker's page fires an invisible request that happens to carry the user's session cookie — the user did not intend that action at all. The session cookie can't tell the difference. Your server can't tell the difference, because from the server's perspective both scenarios look identical: an authenticated request arrived.
CSRF protection adds something to requests that proves they genuinely originated from your application — a secret that the attacker's site cannot obtain or replicate. That something is a CSRF token.
How CSRF Tokens Work
A CSRF token is a random, unpredictable value that your server generates, embeds in every form it serves, and then validates when the form is submitted. Here's the logic:
When your server renders a form, it generates a random token and stores it in the user's server-side session. It also embeds the same token as a hidden field in the form HTML. When the form is submitted, the server compares the token in the POST body with the token stored in the session. If they match, the request is legitimate. If they don't match or if the token is missing, the request gets rejected.
An attacker's page can fire a request to your server. It cannot read your application's HTML to obtain the CSRF token — that would require reading a page from your domain, which the browser's Same-Origin Policy prevents. So the forged request arrives without a valid token and gets rejected.
The token needs to be genuinely random and sufficiently long to be unguessable. A short or predictable token is no protection at all.
Implementing CSRF Protection in PHP From Scratch
Here's a complete, working implementation you can integrate into any PHP application.
Step 1 — Token generation and storage:
<?php
// csrf.php - include this in your application
function generateCsrfToken() {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Generate a new token if one doesn't exist
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$_SESSION['csrf_token_time'] = time();
}
return $_SESSION['csrf_token'];
}
function verifyCsrfToken($submitted_token) {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Check token exists in session
if (empty($_SESSION['csrf_token'])) {
return false;
}
// Check token hasn't expired (1 hour)
if (time() - $_SESSION['csrf_token_time'] > 3600) {
unset($_SESSION['csrf_token'], $_SESSION['csrf_token_time']);
return false;
}
// Use hash_equals to prevent timing attacks
if (!hash_equals($_SESSION['csrf_token'], $submitted_token)) {
return false;
}
return true;
}
function csrfField() {
$token = generateCsrfToken();
return '<input type="hidden" name="csrf_token" value="'
. htmlspecialchars($token, ENT_QUOTES, 'UTF-8') . '">';
}
Step 2 — Adding the token to your forms:
<?php
require 'csrf.php';
session_start();
?>
<form method="POST" action="/update-profile">
<?php echo csrfField(); ?>
<label>Email</label>
<input type="email" name="email" required>
<label>Display Name</label>
<input type="text" name="display_name" required>
<button type="submit">Update Profile</button>
</form>
Step 3 — Validating the token on form submission:
<?php
require 'csrf.php';
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$submitted_token = $_POST['csrf_token'] ?? '';
if (!verifyCsrfToken($submitted_token)) {
// CSRF validation failed
http_response_code(403);
die('Invalid request. Please go back and try again.');
}
// Token is valid — process the form safely
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$display_name = htmlspecialchars(trim($_POST['display_name']), ENT_QUOTES, 'UTF-8');
if (!$email) {
die('Invalid email address.');
}
// Update database
$stmt = $db->prepare("UPDATE users SET email = ?, display_name = ? WHERE id = ?");
$stmt->execute([$email, $display_name, $_SESSION['user_id']]);
// Redirect after success
header('Location: /profile?updated=1');
exit;
}
The hash_equals() function on the comparison is important — it prevents timing attacks where an attacker could determine how many characters of a token match the stored value by measuring response time differences. Always use it for security-sensitive comparisons.
CSRF Protection for AJAX Requests
Modern applications make a lot of requests via JavaScript rather than traditional form submissions. The same protection applies — you just need to deliver the token differently.
A common pattern is to embed the token in a meta tag that JavaScript can read:
<!-- In your HTML head -->
<meta name="csrf-token" content="<?php echo htmlspecialchars(generateCsrfToken(), ENT_QUOTES, 'UTF-8'); ?>">
// In your JavaScript — add to every AJAX request
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Using fetch API
fetch('/api/update-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // send in custom header
},
body: JSON.stringify({ theme: 'dark', notifications: true })
});
<?php
// On the server — validate header-based token for AJAX endpoints
function verifyCsrfHeader() {
$headers = getallheaders();
$submitted_token = $headers['X-CSRF-Token'] ??
$headers['X-Csrf-Token'] ?? '';
return verifyCsrfToken($submitted_token);
}
// In your AJAX endpoint
if (!verifyCsrfHeader()) {
http_response_code(403);
echo json_encode(['error' => 'CSRF validation failed']);
exit;
}
This approach works because an attacker's page cannot read the meta tag content from your domain — cross-origin page reads are blocked by the Same-Origin Policy. The token is visible to your own JavaScript but not to scripts running on other domains.
Per-Request Tokens vs. Per-Session Tokens
The implementation above uses one token per session — the same token is valid for all forms during a session and refreshes when the session expires. This is the most common approach and works well for most applications.
A stricter approach is per-request tokens — each form gets a unique token that becomes invalid after one use. This provides stronger protection against certain edge cases like XSS combined with CSRF, but it breaks the back button in browsers and complicates pages with multiple forms.
For most web applications, per-session tokens with a reasonable expiry time are the right balance. For high-security actions — password changes, account deletion, payment confirmation — consider requiring password re-entry rather than relying on CSRF tokens alone.
What CSRF Protection Does Not Cover
Understanding the limits of CSRF protection is as important as implementing it.
CSRF tokens don't protect against XSS — If an attacker has JavaScript execution on your domain through an XSS vulnerability, they can read your CSRF tokens directly and forge authenticated requests with valid tokens. XSS completely bypasses CSRF protection. This is why both need to be addressed — they're independent defenses against different attack vectors.
CSRF tokens don't protect GET requests — GET requests should never perform state-changing actions. If your application deletes a record or changes settings via a GET request, CSRF tokens on POST forms don't help — those actions can be triggered by an attacker with a simple image or link tag. Reserve CSRF token validation for POST, PUT, PATCH, and DELETE requests, and make sure GET endpoints are genuinely read-only.
CSRF tokens don't protect against compromised sessions — If an attacker has stolen a valid session through other means, they can make requests with that session just as the legitimate user would — and those requests will have valid CSRF tokens because they're generated from the same session.
The SameSite Cookie Attribute — A Modern Complement to CSRF Tokens
Modern browsers support a cookie attribute called SameSite that provides an additional layer of CSRF defence. Setting it to Strict or Lax tells the browser not to send the cookie in cross-site requests.
<?php
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => 'jshook.online',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict' // or 'Lax' for slightly more flexibility
]);
session_start();
With SameSite=Strict, the session cookie won't be sent at all on cross-site requests — which means even a perfectly crafted CSRF attempt fails because the request arrives without a session cookie, as if the user isn't logged in.
However — do not rely on SameSite alone without CSRF tokens. Browser support has improved significantly but older browsers don't support it, and some configurations bypass it. SameSite is an excellent additional layer on top of proper CSRF token implementation, not a replacement for it.
A Checklist Before You Ship
Before deploying any PHP application that handles authenticated user actions, run through this:
- Every form that performs a state-changing action has a hidden CSRF token field
- Every POST endpoint validates the CSRF token before processing anything
- AJAX requests include the CSRF token in a custom header
- Tokens are compared with
hash_equals()not== - Tokens have an expiry time — old sessions don't carry forever-valid tokens
- Session cookies have SameSite, HttpOnly, and Secure flags set
- No state-changing actions happen via GET requests
It takes less than an hour to add CSRF protection to an existing application once you have the helper functions in place. The hidden field in every form and the two-line validation at the top of every POST handler is genuinely all it takes for the majority of use cases.
Final Thought
CSRF is one of those vulnerabilities where the protection is cheap and the consequence of not having it is expensive. An attacker who finds a CSRF-vulnerable action in your application can weaponize it with a few lines of HTML hosted anywhere on the internet. The victim just needs to visit a page while logged into your application — and that's something that can be engineered through a short link in an email or a message.
Tokens, SameSite cookies, and read-only GET endpoints together eliminate virtually all CSRF risk. Build that foundation in from the start and it becomes part of how you naturally write PHP — a default rather than an afterthought.
— Skand K.