CSRF Protection
Concept Core protects state-changing HTTP requests from cross-site request forgery (CSRF) using
a synchronizer token stored in the session. Verification runs in
VerifyCsrfTokenMiddleware before the controller; forms and AJAX receive the token from
CsrfTokenManager.
See also: Middleware → CSRF,
Views → global variables (csrf_token).
Core classes
| Class | Role |
|---|---|
Concept\Core\Http\Security\CsrfTokenManager |
Generates and validate()s the session token |
VerifyCsrfTokenMiddleware |
Verifies POST / PUT / PATCH / DELETE |
ShareViewDataMiddleware |
ViewKey::CSRF_TOKEN → Twig csrf_token |
Concept\Core\Http\Exceptions\Security\CsrfException |
419 Page Expired |
CsrfTokenManager has no dedicated service provider — it is resolved via autowiring
(depends on SessionInterface).
How it works
- On first access,
CsrfTokenManager::getToken()generates a 64-character hex token (random_bytes(32)) and stores it in the session underSessionKey::CSRF_TOKEN(_csrf_token). ShareViewDataMiddlewarepasses the token to Twig as{{ csrf_token }}.- For POST, PUT, PATCH, and DELETE, middleware compares the request token with the session token via
hash_equals(). - If the token is missing or does not match —
CsrfExceptionis thrown (HTTP 419 Page Expired).
Registering middleware
In routes/web.php (or on a route group), register verification after the validation handler but before state changes:
$router->lazyMiddleware(HandleValidationExceptionMiddleware::class);
$router->lazyMiddleware(VerifyCsrfTokenMiddleware::class);
$router->lazyMiddleware(StorePreviousUrlMiddleware::class);
$router->lazyMiddleware(ShareViewDataMiddleware::class);
ShareViewDataMiddleware must be in the stack so templates receive the current token.
A session is required — registered via SessionServiceProvider in
bootstrap/providers/app.php.
Forms (HTML)
In every form that changes data, add a hidden field named _csrf_token:
In the layout, add a meta tag for AJAX (optional):
<meta name="csrf-token" content="{{ csrf_token }}">
<form method="post" action="{{ uri('cabinet.profile.update') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token }}">
{# ... form fields ... #}
</form>
The _csrf_token field is automatically excluded from validation data in
FormRequest (global $globalExcept), so it does not end up in the DTO.
AJAX and browser API calls
VerifyCsrfTokenMiddleware looks for the token in this order:
- Field
_csrf_tokeningetParsedBody()(form or JSON body) - Header
X-CSRF-TOKEN(HttpHeader::X_CSRF_TOKEN) - Header
X-XSRF-TOKEN(HttpHeader::X_XSRF_TOKEN) — viaurldecode()
fetch('/api/profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify(payload)
});
For pure external APIs without a session, CSRF is usually not applied — put such routes in a separate group without VerifyCsrfTokenMiddleware and use other authentication (Bearer, API key).
Errors and responses
CsrfException uses code HttpStatusCode::PAGE_EXPIRED (419). The error handler
(Whoops / production handler) returns HTML or JSON depending on the request — see
Responses.
Common causes of 419:
- Session expired or session cookie missing
- Form opened a long time ago; session token has changed
- Double submit from a cached page without a current
csrf_token
Programmatic access
In services you can obtain the manager from the container:
use Concept\Core\Http\Security\CsrfTokenManager;
/** @var CsrfTokenManager $csrf */
$csrf = $container->get(CsrfTokenManager::class);
$token = $csrf->getToken();
$valid = $csrf->validate($submittedToken);
In tests, pass the same token that is in the test client session.
Protected methods: POST, PUT, PATCH, DELETE
(VerifyCsrfTokenMiddleware::PROTECTED_METHODS). GET, HEAD, OPTIONS are not checked —
do not change state via GET.