symfony/security-csrf
The Symfony Security CSRF component provides a CsrfTokenManager class for generating and validating cross-site request forgery tokens, helping protect your PHP applications from forged form submissions and malicious requests.
Laravel already has session-based CSRF via VerifyCsrfToken, so install this only when you need something Laravel's helper does not give you: scoped token IDs, pluggable token storage (Redis/DB/cookie), rotatable per-action tokens, or framework-agnostic code shared with a Symfony app. Day one is a 15-minute integration.
composer require symfony/security-csrf:^7.4
# PHP 8.1 only (Laravel 10): ^6.4 · PHP 8.4 (Symfony 8): ^8.0
Look at these four contracts first — everything else is a concrete implementation:
| Class | Role |
|---|---|
CsrfTokenManagerInterface |
getToken, refreshToken, removeToken, isTokenValid |
TokenStorageInterface |
getToken (throws TokenNotFoundException), setToken, removeToken, hasToken |
TokenGeneratorInterface |
DefaultTokenGenerator, UriSafeTokenGenerator |
CsrfToken |
Immutable id + value value object |
Step 1 — adapt Laravel's session to TokenStorageInterface (Symfony's NativeSessionTokenStorage needs $_SESSION and will not see Laravel's store):
namespace App\Security;
use Illuminate\Contracts\Session\Session;
use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException;
use Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface;
class LaravelSessionTokenStorage implements TokenStorageInterface
{
private const KEY = '_sf_csrf';
public function __construct(private Session $session) {}
public function getToken(string $tokenId): string
{
$value = $this->session->get(self::KEY.'.'.$tokenId);
if (!\is_string($value)) {
// MUST throw this exact exception: CsrfTokenManager catches it to mint a new token
throw new TokenNotFoundException('No CSRF token stored for "'.$tokenId.'".');
}
return $value;
}
public function setToken(string $tokenId, #[\SensitiveParameter] string $token): void
{
$this->session->put(self::KEY.'.'.$tokenId, $token);
}
public function removeToken(string $tokenId): ?string
{
return $this->session->pull(self::KEY.'.'.$tokenId);
}
public function hasToken(string $tokenId): bool
{
return \is_string($this->session->get(self::KEY.'.'.$tokenId));
}
}
Step 2 — bind it (use scoped, not singleton, see Gotchas):
// AppServiceProvider::register()
$this->app->scoped(TokenStorageInterface::class, fn ($app) => new LaravelSessionTokenStorage($app['session.store']));
$this->app->scoped(CsrfTokenManagerInterface::class, fn ($app) => new CsrfTokenManager(
new \Symfony\Component\Security\Csrf\TokenGenerator\UriSafeTokenGenerator(),
$app->make(TokenStorageInterface::class),
'app_', // namespace prefix so IDs never collide with anything else
));
Step 3 — first use case: a token scoped to one form. In the controller that renders the form:
$token = app(CsrfTokenManagerInterface::class)->getToken('contact_form')->getValue();
return view('contact', ['csrf' => $token]);
<form method="POST" action="/contact">
@csrf
<input type="hidden" name="_csrf_token" value="{{ $csrf }}">
</form>
Then verify it in the receiving controller/middleware. That's the whole loop: one ID on the way in, the same ID on the way out. Everything below builds on it.
The most common day-to-day shape: one middleware, token ID passed as a route middleware parameter.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Session\TokenMismatchException; // Laravel renders this as HTTP 419
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
class VerifyScopedCsrf
{
public function __construct(private CsrfTokenManagerInterface $csrf) {}
public function handle(Request $request, Closure $next, string $tokenId = 'default')
{
if (\in_array($request->method(), ['GET', 'HEAD', 'OPTIONS'], true)) {
return $next($request);
}
$value = $request->input('_csrf_token') ?? $request->header('X-CSRF-Token');
$token = new CsrfToken($tokenId, $value !== null ? (string) $value : null);
if (!$this->csrFIsValid($token)) {
throw new TokenMismatchException('CSRF token mismatch.');
}
return $next($request);
}
private function csrFIsValid(CsrfToken $token): bool
{
try {
return $this->csrf->isTokenValid($token);
} catch (\Throwable) {
return false;
}
}
}
Route::post('/contact', ContactController::class)
->middleware('csrf.scoped:contact_form');
// bootstrap/app.php (Laravel 11+) or Kernel::$routeMiddleware
->withMiddleware(fn ($m) => $m->alias(['csrf.scoped' => \App\Http\Middleware\VerifyScopedCsrf::class]))
Laravel's _token is global to the session: one token defends every form. symfony/security-csrf lets you mint distinct tokens per action, so a token leaked from a low-value form cannot authorise a destructive one.
// Render
$id = "user.{$user->id}.delete";
return view('account.edit', ['deleteToken' => $manager->getToken($id)->getValue()]);
// Handle (one-off action)
if (!$manager->isTokenValid(new CsrfToken($id, $request->input('_csrf_token')))) {
throw new TokenMismatchException();
}
$manager->removeToken($id); // or refreshToken($id) to keep the form reusable
Rules of thumb: IDs must be stable between render and submit, and they must match [a-zA-Z0-9_] (see Gotchas). Good ID schemes: contact_form, user.42.delete, checkout.payment. Bad: a raw request value, a timestamp, or a route name you might rename.
Cookie-authenticated SPAs still need CSRF. Expose the token as JSON rather than a hidden field:
Route::get('/csrf-token', fn () => response()
->json(['token' => app(CsrfTokenManagerInterface::class)->getToken('spa')->getValue()])
->header('Cache-Control', 'no-store'))
->middleware('web');
The client fetches it once per page load, stores it in memory, and sends X-CSRF-Token on every mutation. The middleware from Pattern 1 reads the header, so no code change. Requirements: credentials: 'include' on the fetch, a session cookie that can actually be sent (SameSite=Lax for same-origin; SameSite=None; Secure for cross-origin), and Cache-Control: no-store so a proxy never serves a stale token.
Because storage is just an interface, you can move tokens out of the session entirely — useful for stateless-ish APIs where the session is thin, or when you want a TTL:
class CacheTokenStorage implements TokenStorageInterface
{
public function __construct(private \Illuminate\Contracts\Cache\Repository $cache) {}
public function getToken(string $tokenId): string
{
return $this->cache->get($this->key($tokenId))
?? throw new TokenNotFoundException();
}
public function setToken(string $tokenId, string $token): void
{
$this->cache->put($this->key($tokenId), $token, now()->addHours(2));
}
public function removeToken(string $tokenId): ?string { return $this->cache->pull($this->key($tokenId)); }
public function hasToken(string $tokenId): bool { return $this->cache->has($this->key($tokenId)); }
private function key(string $id): string { return 'csrf:'.sha1($id); }
}
Note the sha1 — it sidesteps the character restrictions and keeps cache keys short.
Generate the token against the same manager the app uses:
public function test_delete_requires_scoped_token(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->delete("/users/{$user->id}", ['_csrf_token' => 'bogus'])
->assertStatus(419);
$token = app(CsrfTokenManagerInterface::class)->getToken("user.{$user->id}.delete")->getValue();
$this->actingAs($user)
->delete("/users/{$user->id}", ['_csrf_token' => $token])
->assertRedirect('/users');
}
$this->withoutMiddleware(VerifyScopedCsrf::class) works too, but test at least one happy path with a real token — that is what catches ID mismatches.
isTokenValid() is a pure hash_equals() comparison against storage; it does not invalidate. If replay matters, call refreshToken($id) (or removeToken($id)) immediately after successful validation.[a-zA-Z0-9_] with an InvalidArgumentException. If you derive IDs from user input, preg_replace('/[^a-zA-Z0-9_]/', '_', $raw) first — otherwise you get a 500 in production that never shows up in tests with clean fixtures.TokenNotFoundException, not return null. CsrfTokenManager catches that exception to mint a first token; returning null from getToken() makes getToken() blow up on a fresh session. This is the #1 custom-adapter bug.singleton under Octane. A singleton constructed with $app['session.store'] will hand a worker's previous request's session to the next one. Bind with scoped(), or resolve the session lazily inside the storage adapter ($this->app->make('session.store') per call). Same class of bug applies to NativeSessionTokenStorage, which is outright unsafe under Octane because $_SESSION persists in the worker.VerifyCsrfToken short-circuits via runningUnitTests(). Custom middleware has no such escape hatch, so existing feature tests that POST without a token will start returning 419 after you wire it up. Grep your test suite before rolling out.new CsrfTokenManager($gen, $store, 'app_') stores key app_contact_form but reports the ID as contact_form. Don't add the prefix yourself in both places.SessionTokenStorage vs NativeSessionTokenStorage vs LaravelSessionTokenStorage. Only the first two ship with the package; the first wants a Symfony\Component\HttpFoundation\Session\SessionInterface (Laravel doesn't provide one), the second wants session_start(). In Laravel, always write the ~20-line adapter shown above.removeToken() on login, logout, password change, and impersonation start/stop. Otherwise a pre-auth token keeps working post-auth if the session ID isn't regenerated.SameOriginCsrfTokenManager (7.2+) is tempting but not drop-in. It skips server-side storage entirely and validates the request's Origin/Referer/Sec-Fetch-Site headers against the host (see changelog fixes #59269 "trust Referer at the same level as Origin", #62077 "add Sec-Fetch-Site", #59146 "use the session only if started"). It relies on a Symfony HttpKernel response listener to issue its cookie — read SameOriginCsrfProtectionListener in symfony/security-http and port that listener to a Laravel middleware before using it. Great for header-carrying API clients; not a one-liner.symfony/* packages; pin to the same major to avoid a duplicate tree: ^6.4 for PHP 8.1 / Laravel 10, ^7.4 for PHP 8.2–8.3 / Laravel 11–12, ^8.0 only on PHP 8.4. Run composer why-not symfony/security-csrf ^7.4 when resolution fails — it names the conflicting component.TokenGeneratorInterface for a custom format (e.g. UUIDv7); decorate CsrfTokenManagerInterface to add logging or metrics around validation failures (a spike in 419s is a useful signal); implement TokenStorageInterface for Redis, a database table, or an HMAC-signed stateless token where isTokenValid() recomputes the signature instead of reading storage. The last one is the cleanest path to true statelessness and about 40 lines of code.setToken()'s parameter #[\SensitiveParameter] (as above) so exception traces don't leak it, and keep tokens out of your response-caching layer entirely.How can I help you explore Laravel packages today?