Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix CSRF in PHP and Laravel

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Keep PreventRequestForgery, which Laravel puts in the web middleware group, on every state-changing route: it accepts a request that the browser marks Sec-Fetch-Site: same-origin, and otherwise requires the session's token in _token or X-CSRF-TOKEN. Put @csrf in every form, never exclude a cookie-authenticated route, and change state only on POST, PUT, PATCH or DELETE.

Stack
  • Language PHP
  • Runtime PHP 8.5.11 (CLI)
  • Framework Laravel 13.34.0 (sessions on SQLite)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 55 KB
On this page

Steps

  1. Find every opt-out: withoutMiddleware() naming PreventRequestForgery (or, in apps that started on Laravel 12 or earlier, VerifyCsrfToken or ValidateCsrfToken), preventRequestForgery(except: [...]) or the deprecated validateCsrfTokens(except: [...]) in bootstrap/app.php, the $except list of an old App\Http\Middleware\VerifyCsrfToken class, and routes outside the web group that still use the session.
  2. Remove the opt-outs for anything a signed-in browser can reach. Keep exclusions only for routes that do not use the session cookie, such as signed webhooks, and verify those with their own signature.
  3. Put @csrf in every POST, PUT, PATCH and DELETE form; for fetch() and XHR, send the token from <meta name="csrf-token"> in an X-CSRF-TOKEN header (Axios sends X-XSRF-TOKEN from the XSRF-TOKEN cookie on its own).
  4. Register state-changing routes with Route::post(), put(), patch() or delete(), never Route::get(), Route::match(['get', 'post']) or Route::any(): the middleware never checks GET, HEAD or OPTIONS.
  5. Leave allowSameSite off unless sibling subdomains must post to the app; with it on, a page on any subdomain you do not fully control can submit forms.
  6. Laravel skips the check while APP_ENV is testing, so run the CSRF regression tests in another environment (this card's phpunit.xml sets APP_ENV=local) with a real session cookie, and send the Origin, Referer and Sec-Fetch-* headers a browser would.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableroutes/email/vulnerable.phpPHP
Route::match(['get', 'post'], '/account/email', function (Request $request) { // GET for the link in the "confirm your new address" email
    $email = (string) $request->input('email'); // the query string or the form
    if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
        return response('Enter a valid email address', 400)->header('Content-Type', 'text/plain');
    }
    $request->user()->forceFill(['email' => $email])->save();

    return redirect('/account');
})->middleware('auth')
    ->withoutMiddleware(PreventRequestForgery::class); // added when a fetch() call from the account page kept getting 419

With IMPL=vulnerable, 7 of 12 tests fail (7 exploit tests).

The fix

Fixedroutes/email/fixed.phpPHP
Route::post('/account/email', function (Request $request) { // a link cannot reach it
    $email = (string) $request->input('email');
    if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
        return response('Enter a valid email address', 400)->header('Content-Type', 'text/plain');
    }
    $request->user()->forceFill(['email' => $email])->save();

    return redirect('/account');
})->middleware('auth');

With IMPL=fixed, 12 of 12 tests pass.

The fix: the token in the form and the page

Fixedresources/views/account.blade.phpBlade
{{-- Scripts send this token in an X-CSRF-TOKEN header with fetch(). --}}
<meta name="csrf-token" content="{{ csrf_token() }}">
</head>
<body>
<p>Signed in as {{ $user->username }}. Email: {{ $user->email }}</p>
<form method="post" action="/account/email">
  @csrf {{-- a hidden _token field with the session's CSRF token --}}
  <label>New email <input type="email" name="email" required></label>
  <button>Change email</button>
</form>

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fixroutes/email/wrong.phpPHP
Route::match(['get', 'post'], '/account/email', function (Request $request) { // the session cookie is SameSite=Lax, so GET is "safe"
    $referer = $request->headers->get('Referer');
    // Some browsers and privacy tools strip the Referer, so allow requests without one.
    if (! $request->isMethod('GET') && $referer !== null && ! str_contains($referer, $request->getHost())) {
        return response('Cross-site request refused', 403);
    }
    $email = (string) $request->input('email');
    if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
        return response('Enter a valid email address', 400)->header('Content-Type', 'text/plain');
    }
    $request->user()->forceFill(['email' => $email])->save();

    return redirect('/account');
})->middleware('auth')
    ->withoutMiddleware(PreventRequestForgery::class); // Laravel's check broke fetch(); ours accepts it

With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Feature/CsrfTest.phpPHP
// Exploit: each forged request carries Alice's session cookie. Browsers withhold a
// SameSite=Lax cookie from cross-site POSTs, but not from sibling subdomains or top-level
// GET navigations, and older browsers ignore SameSite, so the server must refuse anyway.

private function forge(Browser $browser, array $headers, array $fields = []): TestResponse
{
    return $browser->post('/account/email', $headers, [...$fields, 'email' => 'mallory@evil.example']);
}

public function test_exploit_cross_site_form_post_without_a_token_is_refused(): void
{
    $this->assertRefused($this->forge($this->signIn('alice'), self::formFrom(self::EVIL)));
}

public function test_exploit_foreign_origin_is_refused_when_the_browser_sends_no_fetch_metadata(): void
{
    $this->assertRefused($this->forge($this->signIn('alice'), ['Origin' => self::EVIL, 'Referer' => self::EVIL.'/']));
}

public function test_exploit_sibling_subdomain_is_refused_although_samesite_cookies_reach_it(): void
{
    $this->assertRefused($this->forge($this->signIn('alice'), self::formFrom(self::SIBLING, 'same-site')));
}

public function test_exploit_token_from_the_attackers_own_session_is_refused(): void
{
    $bobsFields = Html::hiddenFields($this->accountPage($this->signIn('bob')));
    $this->assertRefused($this->forge($this->signIn('alice'), self::formFrom(self::EVIL), $bobsFields));
}

public function test_exploit_referer_that_merely_contains_the_host_name_is_refused(): void
{
    $this->assertRefused($this->forge($this->signIn('alice'), self::formFrom(self::LOOKALIKE)));
}

public function test_exploit_page_that_suppresses_the_referer_is_refused(): void
{
    // <meta name="referrer" content="no-referrer"> on the attacking page: no Referer, Origin: null.
    $headers = ['Origin' => 'null', 'Sec-Fetch-Site' => 'cross-site', 'Sec-Fetch-Mode' => 'navigate', 'Sec-Fetch-Dest' => 'document'];
    $this->assertRefused($this->forge($this->signIn('alice'), $headers));
}

public function test_exploit_cross_site_get_navigation_cannot_change_the_email(): void
{
    // Lax cookies are sent on top-level GET navigations, so only the method protects this.
    $response = $this->signIn('alice')->get('/account/email?email=mallory%40evil.example', self::linkFrom(self::EVIL));
    $this->assertSame('alice@example.test', $this->emailOf('alice'), "a GET changed Alice's email (HTTP {$response->status()})");
    $this->assertGreaterThanOrEqual(400, $response->status());
}

Pitfalls

  • Excluding a route because fetch() calls kept getting 419 leaves the session cookie as the only check. That is this card's vulnerable implementation; send the token in X-CSRF-TOKEN instead.
  • A home-made Referer check is this card's wrong implementation: str_contains($referer, $host) accepts https://portal.example.test.evil.example, and letting requests without a Referer through accepts any page that sets Referrer-Policy: no-referrer.
  • SameSite=Lax, Laravel's default for the session cookie, is not a CSRF defence on its own: browsers still send the cookie on top-level GET navigations and on requests from sibling subdomains, which are the same site.
  • Origin verification relies on Sec-Fetch-Site, which browsers send only to potentially trustworthy (HTTPS) origins. Without it, or over plain HTTP, the middleware falls back to the token, and in originOnly mode it refuses the request with 403.
  • A token mismatch is a TokenMismatchException, which Laravel renders as 419 Page Expired; render it as an error page that offers a fresh form rather than catching it and continuing.
  • The XSRF-TOKEN cookie is readable by page scripts on purpose, for Axios and Angular; an XSS bug can therefore read the token, so CSRF protection does not survive XSS.
  • In Laravel 13 the middleware is Illuminate\Foundation\Http\Middleware\PreventRequestForgery and the bootstrap method is preventRequestForgery(); validateCsrfTokens() still works but is marked deprecated in the framework source.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csrf/php-laravel does this for you in the pinned composer image, so it needs Docker).
  2. Against your own app, sign in, then submit the state-changing form from a page on another origin (a local HTML file served from another port works), and again from a sibling subdomain and by following a GET link: every attempt must get 419 or 403, or 405 for the GET, and change nothing.
  3. grep for withoutMiddleware(, except:, Route::match( and Route::any(; Semgrep's Laravel rule only checks that forms contain @csrf.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix CSRF in PHP and Laravel
ToolRuleWhat it found here
Semgrepphp.laravel.security.laravel-blade-form-missing-csrf.laravel-blade-form-missing-csrfNo findings: both forms contain @csrf. The rule checks templates only, so it cannot see a route that opts out with withoutMiddleware(PreventRequestForgery::class), answers GET, or is listed in preventRequestForgery(except: ...), which is how both broken implementations fail.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/<id> resources
Semgrepr/php (every PHP rule in the Semgrep registry, 47 rules)No findings on any implementation.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes resources config bootstrap
Psalmtaint analysis (all taint kinds)Psalm taint analysis with the Laravel pluginNo issues: CSRF is about which requests the server accepts, not about data flow, so there is nothing for taint analysis to report.Checked: Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6; psalm --taint-analysis over app/ and routes/

grep for withoutMiddleware(, except: and Route::match( and Route::any( on state-changing routes. CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePHP
RuntimePHP 8.5.11 (CLI)
FrameworkLaravel 13.34.0 (sessions on SQLite)
laravel/framework13.34.0
phpunit/phpunit13.4.0
pdo_sqlite (bundled with PHP)PHP 8.5.11; SQLite 3.53.4

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the csrf/php-laravelfolder.

Shell
composer install --no-interaction --no-progress
Shell
# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xml

Without PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'composer install --no-interaction --no-progress'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=fixed \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=vulnerable \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=wrong \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs csrf/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 55 KB

Verified

Machine
Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
Toolchain
  • php 8.5.11 (CLI)
  • laravel/framework 13.34.0
  • phpunit/phpunit 13.4.0
  • composer 2.10.3
  • semgrep 1.180.0
  • psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
Matrix
fixed: 12 of 12 tests pass; vulnerable: 7 of 12 tests fail (7 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
Test results
fixed 12/12 pass; vulnerable 7/12 fail (7 exploit tests); wrong 3/12 fail (3 exploit tests), each test run in a container with --network=none after composer install
Dependency freshness
Laravel 13.35.0 (2026-10-06) is newer but was five days old and is not a security release, so the cards pin 13.34.0 (2026-09-29); laravel/prompts is held at 0.3.24 for the same reason (0.3.25 is from 2026-10-06), and PHPUnit 13.4.1 (2026-10-05) was six days old, so the cards use 13.4.0 (2026-10-02). Every other locked package is at least seven days old. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. Laravel 13.x: Preventing CSRF requests (PreventRequestForgery)
  3. Laravel 13.x: Origin verification (Sec-Fetch-Site, originOnly, allowSameSite)
  4. Laravel 13.x: Excluding URIs from CSRF protection
  5. Laravel 13.x: X-CSRF-TOKEN
  6. Laravel 13.x: Laravel's default middleware groups
  7. W3C Fetch Metadata Request Headers: Sec-Fetch-Site
  8. CWE-352: Cross-Site Request Forgery (CSRF)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.