What Cross-Site Request Forgery Is and Why It Works

A cross-site request forgery (CSRF) attack tricks a logged-in user into submitting a request they did not intend to make. The browser sends cookies automatically with every HTTP request to a domain. If a user is logged into your application and visits a page on a different domain that sends a request to your application, the browser includes the user's session cookie. Your application has no way to distinguish the forged request from a legitimate one without explicit protection.

The consequences depend entirely on what the vulnerable endpoint does. A CSRF attack on an account deletion endpoint permanently deletes the user's account. An attack on a role change endpoint elevates an attacker's account to admin privileges. An attack on a payment processing endpoint transfers money to the wrong account. CSRF is ranked in the OWASP Top 10 because it is consistently present in applications that do not explicitly implement token-based protection, and because the attack requires no visibility into the target application, only the ability to make the victim's browser send a request.

The attack surface is any endpoint that processes state-changing requests using session cookies for authentication. This includes form submissions, AJAX calls, and even image loads that trigger a state change. Some web applications use GET requests for actions, which is itself a CSRF vulnerability. If the endpoint processes requests based solely on a valid session cookie, it is vulnerable to CSRF.

The Synchronised Token Pattern: The Standard CSRF Defence

The standard CSRF protection is a cryptographically random token that is generated per session, embedded in every form as a hidden field, and validated on the server when the form is submitted. The token must be unpredictable and unique per session. Using the same token across all users or across all sessions defeats the protection entirely.

<?php

function generate_csrf_token(): string {
    if (session_status() === PHP_SESSION_NONE) {
        session_start();
    }

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

    return $_SESSION['csrf_token'];
}

function validate_csrf_token(): void {
    $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

    if ($method === 'GET') {
        return;
    }

    $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;

    if (empty($token)) {
        http_response_code(403);
        exit('CSRF token missing');
    }

    if (!hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
        http_response_code(403);
        exit('CSRF token invalid');
    }
}

Use random_bytes(32) to generate a 256-bit token. This is the minimum length needed to make the token unguessable within any practical timeframe. Do not use rand(), uniqid(), microtime(), or mt_rand(). All of these are predictable if the attacker knows approximately when the token was generated. Do not use a hash of the session ID either. Session IDs are often predictable, and an attacker who can guess the session ID through session fixation or other attacks can compute the token.

Use hash_equals() for the comparison, not == or ===. The == operator is vulnerable to timing analysis attacks where subtle differences in comparison time reveal the token value bit by bit. hash_equals() performs a constant-time comparison that is not vulnerable to timing analysis.

Embedding Tokens in HTML Forms

Include the token in every form as a hidden field. The token must come from the session, not be generated fresh during form rendering. If each form has a different token, validation fails because the form submission sends a token that does not match the session token. Store the session token once per session and use it across all forms on the same page.

<form action="/profile/update" method="POST">
    <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrf_token); ?>" />
    <label for="email">Email</label>
    <input type="email" id="email" name="email" required />
    <button type="submit">Update Profile</button>
</form>

Remember to pass the token to your view from the controller or page handler that renders the form. The token should be generated or retrieved from the session before the form is displayed.

Sending Tokens with AJAX Requests

For AJAX requests, send the token in a header. The header approach prevents the token from appearing in server access logs, which would happen if the token were in a URL query string. This is the standard practice for API endpoints and JavaScript-driven form submissions.

<meta name="csrf-token" content="<?php echo htmlspecialchars($csrf_token); ?>">

<script>
document.addEventListener('DOMContentLoaded', function() {
    const token = document.querySelector('meta[name="csrf-token"]').content;

    fetch('/api/profile/update', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': token,
            'X-Requested-With': 'XMLHttpRequest'
        },
        body: JSON.stringify({ email: '[email protected]' })
    })
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error('Request failed:', error));
});
</script>

The X-Requested-With header is a conventional indicator that the request is from AJAX, not a form submission. Some server-side frameworks including CodeIgniter and Laravel check for this header as part of their CSRF implementation. Check your framework's documentation for any specific header requirements. For applications with specific security needs, reviewing the PHP security checklist for business websites can help identify other areas that need attention alongside CSRF protection.

Validating Tokens on Every State-Changing Request

Every POST, PUT, and DELETE request must validate the CSRF token before processing. This should be implemented as middleware or a pre-controller hook that runs before any state-changing logic. If the token is missing or invalid, return a 403 Forbidden response and do not process the request.

<?php

function csrf_guard(): void {
    $exempt_routes = ['/webhook/stripe', '/api/external'];
    $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);

    foreach ($exempt_routes as $route) {
        if (strpos($path, $route) === 0) {
            return;
        }
    }

    $safe_methods = ['GET', 'HEAD', 'OPTIONS'];
    $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

    if (in_array($method, $safe_methods, true)) {
        return;
    }

    $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';

    if (!hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
        http_response_code(403);
        echo json_encode(['error' => 'CSRF validation failed']);
        exit;
    }
}

Exempt only routes that have their own authentication mechanism. Webhook endpoints that verify request signatures from external services do not need CSRF tokens because they verify the signature instead. Every other state-changing endpoint requires the token.

Regenerating Tokens on Login and Session Changes

Regenerate the CSRF token when the session changes. The most important session change is user login. Login typically involves issuing a new session ID to prevent session fixation attacks, where an attacker sets a user's session ID before the user logs in. The CSRF token must also be regenerated at login so that a session fixation attack does not also fix the CSRF token.

<?php

function login_user(int $user_id): void {
    session_regenerate_id(true);

    $_SESSION['user_id'] = $user_id;
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

function logout_user(): void {
    $_SESSION = [];
    session_destroy();
    setcookie(session_name(), '', time() - 3600, '/');
}

The session_regenerate_id(true) call destroys the old session and creates a new one with a new session ID, preventing session fixation. Regenerating the CSRF token at the same time ensures the old session's token cannot be used to forge requests on the new session. For applications handling sensitive data, considering a broader server security setup alongside session management provides layered protection.

The Double Submit Cookie Pattern

The double submit cookie pattern is an alternative to server-side session token storage. The server sets a cookie containing the CSRF token, and the form submission must include the same token as a POST field. The server validates that the cookie value and the POST field value match. This approach works across load-balanced servers without session synchronisation, but it is vulnerable to XSS attacks that can read the cookie value.

<?php

$token = $_COOKIE['csrf_token'] ?? null;

if (empty($token)) {
    $token = bin2hex(random_bytes(32));
    setcookie('csrf_token', $token, [
        'expires' => 0,
        'path' => '/',
        'domain' => '',
        'secure' => true,
        'httponly' => true,
        'samesite' => 'Strict'
    ]);
    $_COOKIE['csrf_token'] = $token;
}
<form action="/account/update" method="POST">
    <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($token); ?>" />
    <!-- form fields -->
</form>
<?php
if (!hash_equals($_COOKIE['csrf_token'] ?? '', $_POST['csrf_token'] ?? '')) {
    http_response_code(403);
    exit('CSRF validation failed');
}

The session-based token is more secure because it cannot be read by JavaScript. Use it for any application where CSRF consequences are significant. Use double submit only when server-side session storage is genuinely not available, and understand that any XSS vulnerability in your application defeats this protection entirely.

SameSite Cookies as Defence in Depth

The SameSite attribute on cookies is a browser-level CSRF protection. SameSite=Lax tells the browser not to send the cookie with cross-site POST requests. SameSite=Strict prevents the cookie from being sent with any cross-site request, including navigation links. The attribute is supported in all modern browsers and provides a useful additional layer of protection, but it should not replace CSRF tokens because not all browsers support it and it does not protect against subdomain attacks.

<?php

session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'domain' => '',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax'
]);

session_start();

SameSite=Lax is the appropriate default for most applications. SameSite=Strict can break legitimate flows where the user navigates to your site from an external link. The cookie is not sent on the initial request, which may require re-authentication. Lax sends the cookie with top-level GET requests but not with POST requests from other origins, which provides most of the protection while preserving navigation flows.

Validating Origin and Referer Headers

Validate the Origin header as an additional CSRF check. The browser sets the Origin header to the scheme and host of the page that initiated the request. A cross-site request has an Origin header that does not match your domain. Check for the Origin header and reject requests that do not originate from your site.

<?php

function validate_origin(): bool {
    $origin = $_SERVER['HTTP_ORIGIN'] ?? '';

    if (empty($origin)) {
        return false;
    }

    $allowed_host = $_SERVER['HTTP_HOST'] ?? '';
    $origin_parsed = parse_url($origin, PHP_URL_HOST);

    return $origin_parsed === $allowed_host;
}

if (!validate_origin()) {
    http_response_code(403);
    exit('Request origin not allowed');
}

The Origin header is not sent on all requests. Older browsers, some privacy extensions, and certain configurations may omit it. For this reason, it cannot be the primary CSRF protection. Use it alongside tokens as defence in depth. The Referer header is similar but considered less reliable, as it can be stripped by privacy extensions or corporate proxies.

Why GET Requests Must Not Have Side Effects

GET requests should never have side effects. A link like <a href="/account/delete?id=5"> that changes data is a CSRF vulnerability by design. GET requests are supposed to be idempotent, meaning safe to repeat with no side effects. If your application uses GET requests to trigger actions, you have a CSRF vulnerability that no token-based protection can fix, because the token itself should not be transmitted in a GET request. It would appear in URLs, server access logs, browser history, and the Referer header when the user navigates to an external site.

The fix is to change actions triggered by GET requests to use POST. This is a straightforward refactor. Move the action logic to a POST handler, add CSRF token validation, and update any links that trigger the action to use forms or JavaScript.

<!-- Before: GET request with side effect (vulnerable) -->
<a href="/account/delete?id=<?php echo $account_id; ?>">Delete Account</a>

<!-- After: POST form with CSRF token -->
<form action="/account/delete" method="POST" class="inline-form">
    <input type="hidden" name="id" value="<?php echo $account_id; ?>" />
    <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrf_token); ?>" />
    <button type="submit" class="btn-danger" onclick="return confirm('Delete this account?');">Delete Account</button>
</form>

Common CSRF Protection Mistakes

Only protecting some forms is a common failure. CSRF protection must cover every state-changing endpoint. An attacker does not need to target the login form. They target the profile update form, the notification settings form, or any endpoint that can be leveraged to change application state in the attacker's favour.

Using a global static token that is the same for all users and sessions means an attacker who obtains the token through a cross-site scripting attack, through information disclosure in server responses, or by guessing a weak token generation algorithm can forge requests for any user. Each session must have a unique, unpredictable token generated using random_bytes().

Accepting CSRF tokens in GET requests creates a significant gap. The token appears in URL query strings, server access logs, browser history, and the Referer header when the user navigates to an external site. Use POST for token transmission, not GET.

Not protecting AJAX requests is a common oversight. If your application uses fetch() or XMLHttpRequest for state-changing operations, those requests also need CSRF tokens. The meta tag approach described above is the cleanest implementation. Write the token to a meta tag in the page head, and have your JavaScript read it from the meta tag and include it in a header for every request.

Clearing the token after validation causes legitimate submissions to fail when the user refreshes the page or uses the browser back button. A CSRF token is a session-level identifier. It remains valid for the session, not for a single request. Using one-time tokens that become invalid after one use is not CSRF protection. That is an OTP system that does not belong in CSRF contexts and creates poor user experience.

Implementing CSRF Protection as Middleware

For modern PHP applications, implement CSRF validation as middleware that runs on every request before the controller is invoked. This ensures consistent enforcement and avoids the need to add validation code to every controller method manually.

<?php

class CsrfMiddleware {
    private array $exempt_routes;

    public function __construct(array $exempt_routes = []) {
        $this->exempt_routes = $exempt_routes;
    }

    public function handle(array $request, callable $next): array {
        if ($this->is_exempt($request['uri'])) {
            return $next($request);
        }

        $safe_methods = ['GET', 'HEAD', 'OPTIONS'];
        if (in_array($request['method'], $safe_methods, true)) {
            return $next($request);
        }

        $token = $request['post']['csrf_token']
              ?? $request['headers']['X-CSRF-TOKEN']
              ?? '';

        if (!hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
            return [
                'status' => 403,
                'body' => ['error' => 'CSRF validation failed']
            ];
        }

        return $next($request);
    }

    private function is_exempt(string $uri): bool {
        foreach ($this->exempt_routes as $route) {
            if (strpos($uri, $route) === 0) {
                return true;
            }
        }
        return false;
    }
}

Only exempt routes that have their own external signature verification. For example, Stripe webhooks verify signatures using their own mechanism, not CSRF tokens. Every other state-changing route must pass through the CSRF middleware. Building this into your application architecture early means you never accidentally miss an endpoint.

CSRF Protection in the Context of Web Application Security

CSRF tokens are one layer of a broader security strategy. They protect against forged requests but do not address other attack vectors. Cross-site scripting (XSS) can bypass CSRF protection because JavaScript running on your domain can read tokens from the DOM and include them in forged requests. This is why input sanitisation and output encoding remain important alongside CSRF tokens.

For applications that need broader protection, a web application firewall can help detect and block malicious requests before they reach your application code. Combining proper CSRF implementation with other security measures creates a more robust defence. If you are evaluating your application's security posture, reviewing the web application firewall options can provide context on available protection layers.

Email-based attacks often combine CSRF with other techniques. Phishing emails may direct users to pages that exploit CSRF vulnerabilities, particularly on sites that lack proper token validation. Understanding how CSRF relates to other attack patterns helps when designing security controls. The SPF, DKIM, and DMARC explained guide covers related email authentication measures that protect against email spoofing, which is a separate but related concern.

Building CSRF Protection Into Your Development Workflow

CSRF protection works best when it is built into your development workflow from the start, not added as an afterthought when a vulnerability is discovered. Using a framework with built-in CSRF handling reduces the chance of accidentally missing an endpoint. If you are building custom forms or API endpoints, make token validation part of your code review checklist.

Document which routes are exempt from CSRF protection and why. This prevents future developers from accidentally removing protection from sensitive endpoints. Exempt routes should have their own documented authentication mechanism, such as webhook signature verification.

When auditing an existing application, look for all forms and AJAX endpoints that modify data. Check that each one validates a session-based CSRF token. Pay particular attention to administrative interfaces, user settings, and any endpoint that triggers emails or modifies user permissions.

If you need help reviewing your current PHP application for CSRF vulnerabilities or other security concerns, you can get in touch with details of your application, the PHP version you use, and the frameworks involved. A practical security review can identify where token validation is missing and help you implement consistent protection across your codebase.