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.
Cross-Site Request Forgery is one of those vulnerabilities that's easy to explain, easy to understand conceptually, and surprisingly easy to miss when you're building forms and API endpoints under time pressure. It's been in the OWASP Top 10 for most of the last two decades. Modern frameworks include CSRF protection by default for good reason — but when you're writing custom PHP, using an older framework, or making assumptions about modern browser protections, the gaps can be significant.
This post explains precisely how CSRF works, what the SameSite cookie attribute does and doesn't protect against, and how to implement solid CSRF protection in a PHP application including APIs that use both session cookies and token-based authentication.
Understanding the Attack: How CSRF Works
CSRF exploits the fact that browsers automatically include cookies with every request to a domain, regardless of where the request originated. When you're logged into yourbank.com and you visit a completely unrelated page attacker.com, any request that attacker.com's JavaScript makes to yourbank.com will automatically include your session cookie for yourbank.com. The bank's server receives the request, sees a valid session cookie, and may process it as if you initiated it.
A concrete example: you're logged into a web application, and while your tab is open, you click a link that takes you to a malicious page. That page contains HTML like this:
<!-- Hidden on attacker.com -->
<form action="https://yourapp.com/api/change-email" method="POST" id="csrf-form">
<input type="hidden" name="email" value="attacker@evil.com">
</form>
<script>
document.getElementById('csrf-form').submit();
</script>
When this page loads, the form submits automatically. Your browser sends a POST request to yourapp.com/api/change-email with your session cookie automatically attached. If your server processes this request based solely on the session cookie being valid, your email address has been changed to the attacker's — and the attacker can now trigger a password reset to take over your account completely.
More sophisticated attacks use fetch or XMLHttpRequest for same-origin requests, or exploit GET endpoints that change state (a well-known antipattern that makes CSRF trivially exploitable via image tags: <img src="https://yourapp.com/delete-account?confirm=yes">).
What CSRF Can and Cannot Achieve
CSRF allows an attacker to cause an authenticated user to take actions they didn't intend: changing account settings, making purchases, transferring funds, posting content, deleting data, changing passwords or email addresses. These are "state-changing" operations triggered via the victim's authenticated session.
CSRF cannot directly read response data. Same-origin policy prevents a cross-origin page from reading the response to a cross-origin request. So while an attacker can trigger your email change, they can't use CSRF to read your inbox or account balance — those require XSS or direct server compromise. This distinction matters for deciding which endpoints need CSRF protection: focus on anything that changes state.
The SameSite Cookie Attribute: Substantial Protection With Important Gaps
The SameSite cookie attribute was introduced specifically to mitigate CSRF, and modern browser defaults have shifted significantly. Understanding what it does and doesn't protect helps you decide where explicit CSRF tokens are still needed.
<?php
// Session cookie with SameSite protection
session_start();
session_set_cookie_params([
'lifetime' => 0, // Session cookie (expires when browser closes)
'path' => '/',
'domain' => 'yoursite.com',
'secure' => true, // HTTPS only
'httponly' => true, // Prevents JavaScript access
'samesite' => 'Lax' // The key attribute
]);
session_regenerate_id(true);
setcookie('PHPSESSID', session_id(), session_get_cookie_params());
SameSite=Strict sends the cookie only when the request originates from the same site as the cookie's domain. This provides the strongest CSRF protection — a request from attacker.com to yourapp.com won't include the cookie. The trade-off: links from external sites (emails, Google search results) won't include the cookie either, which means users clicking a link from an email to a page requiring authentication will be treated as unauthenticated. This is too disruptive for most applications.
SameSite=Lax (the current browser default for cookies without explicit SameSite setting) sends the cookie for top-level navigation with "safe" methods (GET, HEAD) but not for cross-site requests using POST, PUT, DELETE, etc. This blocks the classic CSRF form submission attack above (since it uses POST) while allowing normal navigation. Lax is the practical baseline for most applications.
SameSite=None requires the Secure attribute and sends the cookie with all cross-origin requests. Only appropriate for cookies that explicitly need to work in cross-site contexts (like third-party embedded widgets).
Important gaps in SameSite protection: SameSite=Lax doesn't protect against GET-based state changes. Any application that processes state changes via GET requests (regardless of SameSite) is still vulnerable to image-based CSRF attacks. Additionally, SameSite protection depends on browser implementation — older browsers don't support it. And certain browser behaviors around top-level navigation, redirects, and same-site vs. cross-site determination have nuances that mean SameSite=Lax alone shouldn't be your only CSRF defense.
CSRF Token Implementation
CSRF tokens provide defense that doesn't rely on browser behavior and works regardless of the SameSite implementation. The principle: embed a random secret in every form that changes state. When the form is submitted, verify the token matches what you issued. An attacker on a different site can't read your pages to get the token (same-origin policy prevents this), so they can't craft a valid CSRF request.
<?php
class CSRFProtection {
private const TOKEN_LENGTH = 32; // 32 bytes = 256 bits
private const SESSION_KEY = 'csrf_tokens';
private const TOKEN_LIFETIME = 3600; // 1 hour
/**
* Generate a new CSRF token for a specific action
* Using per-action tokens provides more granular protection than a single site-wide token
*/
public function generateToken(string $action = 'default'): string {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
$token = bin2hex(random_bytes(self::TOKEN_LENGTH));
if (!isset($_SESSION[self::SESSION_KEY])) {
$_SESSION[self::SESSION_KEY] = [];
}
// Store token with action and expiry
$_SESSION[self::SESSION_KEY][$action] = [
'token' => $token,
'expires' => time() + self::TOKEN_LIFETIME,
'created' => time()
];
return $token;
}
/**
* Verify a CSRF token from a request
* Checks both the hidden field and the X-CSRF-Token header (for AJAX)
*/
public function verifyToken(string $action = 'default'): bool {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
// Get submitted token from POST body or header
$submittedToken = $_POST['csrf_token']
?? $_SERVER['HTTP_X_CSRF_TOKEN']
?? null;
if (!$submittedToken) {
return false;
}
$storedData = $_SESSION[self::SESSION_KEY][$action] ?? null;
if (!$storedData) {
return false;
}
if (time() > $storedData['expires']) {
unset($_SESSION[self::SESSION_KEY][$action]);
return false;
}
// Use hash_equals to prevent timing attacks
$valid = hash_equals($storedData['token'], $submittedToken);
if ($valid) {
// Rotate the token after successful use (optional but recommended)
unset($_SESSION[self::SESSION_KEY][$action]);
}
return $valid;
}
/**
* Require valid CSRF token, abort with 403 if invalid
*/
public function requireValidToken(string $action = 'default'): void {
if (!$this->verifyToken($action)) {
error_log('[CSRF] Invalid token from IP: ' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown') .
' Action: ' . $action);
http_response_code(403);
echo json_encode(['error' => 'Invalid or expired security token. Please refresh the page.']);
exit;
}
}
}
// Usage in a form:
$csrf = new CSRFProtection();
$token = $csrf->generateToken('change_email');
?>
<form method="POST" action="/settings/change-email">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($token) ?>">
<input type="email" name="new_email" required>
<button type="submit">Update Email</button>
</form>
<?php
// In the form handler:
session_start();
$csrf = new CSRFProtection();
$csrf->requireValidToken('change_email');
// Now safe to process the request
$newEmail = filter_input(INPUT_POST, 'new_email', FILTER_VALIDATE_EMAIL);
// ... process email change
CSRF Protection for AJAX Requests
Single-page applications and AJAX-heavy frontends need a slightly different approach. The token can be embedded in the initial page load and sent as a request header:
<?php
// On the main page, embed the token for JavaScript to use
$csrf = new CSRFProtection();
$apiToken = $csrf->generateToken('api');
?>
<meta name="csrf-token" content="<?= htmlspecialchars($apiToken) ?>">
<script>
// Set up a global fetch wrapper that automatically includes the CSRF token
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
async function apiFetch(url, options = {}) {
const defaultHeaders = {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // Sent as a custom header
};
return fetch(url, {
...options,
headers: {
...defaultHeaders,
...(options.headers || {})
}
});
}
// Usage:
apiFetch('/api/user/update', {
method: 'POST',
body: JSON.stringify({ name: 'New Name' })
}).then(r => r.json()).then(data => console.log(data));
</script>
<?php
// In your API endpoint, check either the POST field or the header
function requireCSRF(): void {
$csrf = new CSRFProtection();
$token = $_POST['csrf_token']
?? $_SERVER['HTTP_X_CSRF_TOKEN']
?? null;
if (!$token || !$csrf->verifyToken('api')) {
http_response_code(403);
echo json_encode(['error' => 'CSRF validation failed']);
exit;
}
}
// For APIs using Bearer token authentication (JWT, API keys)
// CSRF tokens are typically NOT needed if:
// 1. The endpoint requires an Authorization header (can't be set by forms or img tags cross-site)
// 2. The endpoint accepts only JSON content-type (forms can't send JSON cross-site)
// CSRF tokens ARE still needed for cookie-authenticated AJAX APIs
When Token-Based Auth Changes the Picture
CSRF vulnerabilities are specific to cookie-based authentication. If your API is authenticated via Authorization headers (Bearer tokens, API keys), cross-site requests can't include those headers — browsers enforce this as part of the same-origin policy — so CSRF doesn't apply in the same way.
The decision table: use CSRF tokens for all forms on session-cookie authenticated applications; use CSRF tokens for AJAX calls on session-cookie authenticated applications; skip CSRF tokens for API endpoints that require an Authorization header and don't accept session cookies; still consider SameSite=Strict or Lax on all session cookies as an additional layer regardless.
The nuance: if your application uses both cookie sessions for the web interface and Bearer tokens for the API, and the API also accepts cookie authentication as a fallback, you need CSRF protection on the API too. Mixing authentication mechanisms requires careful thought about which requests can be forged.
Testing Your CSRF Protection
Testing CSRF protection is straightforward with Burp Suite. Capture a state-changing request that should be CSRF-protected. Send it to Repeater. Remove the CSRF token field. Resend — should get a 403 or equivalent error. Try submitting a random token instead of the correct one — should also fail. Try replaying a previously valid token — should fail if you're rotating tokens on use.
Also test that your protection works for different HTTP methods if your application uses PATCH, PUT, or DELETE in addition to POST — a common oversight is protecting POST forms but forgetting AJAX PUT/PATCH endpoints that also modify state.
CSRF protection is not complicated to implement correctly once you understand the mechanics. The critical habit is applying it consistently — every state-changing endpoint, every form, every authenticated AJAX call. A single unprotected endpoint is an open door. — Skand K.