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

ClassRole
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

  1. On first access, CsrfTokenManager::getToken() generates a 64-character hex token (random_bytes(32)) and stores it in the session under SessionKey::CSRF_TOKEN (_csrf_token).
  2. ShareViewDataMiddleware passes the token to Twig as {{ csrf_token }}.
  3. For POST, PUT, PATCH, and DELETE, middleware compares the request token with the session token via hash_equals().
  4. If the token is missing or does not match — CsrfException is 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:

  1. Field _csrf_token in getParsedBody() (form or JSON body)
  2. Header X-CSRF-TOKEN (HttpHeader::X_CSRF_TOKEN)
  3. Header X-XSRF-TOKEN (HttpHeader::X_XSRF_TOKEN) — via urldecode()
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.