Skip to content

marko/security

CSRF protection and security headers middleware --- secure your routes with drop-in middleware. Two middleware classes, both registered globally, cover the most common web security needs: CsrfMiddleware validates tokens on every state-changing request, and SecurityHeadersMiddleware adds protective response headers (HSTS, CSP, X-Frame-Options, etc.). Both are configured via config/security.php.

For cross-origin requests, install marko/cors. marko/security used to ship its own CorsMiddleware with security.cors.* config; it was removed so there is a single CORS implementation. Replace any #[Middleware] reference to the old class with marko/cors (which registers itself globally) and move the cors block of config/security.php to config/cors.php.

Terminal window
composer require marko/security

Requires marko/session and marko/encryption for CSRF token management.

Both middleware classes read from config/security.php:

config/security.php
return [
'csrf' => [
'session_key' => '_csrf_token',
],
'headers' => [
'x_content_type_options' => 'nosniff',
'x_frame_options' => 'SAMEORIGIN',
'x_xss_protection' => '0',
'strict_transport_security' => 'max-age=31536000; includeSubDomains',
'referrer_policy' => 'strict-origin-when-cross-origin',
'content_security_policy' => "default-src 'self'",
],
];

csrf.session_key is the session key CsrfTokenManager stores the token under.

Installing marko/security protects every route. Its module.php registers CsrfMiddleware as global middleware (running inside the session middleware), so a POST, PUT, PATCH or DELETE to any matched route must carry a valid token or it is rejected with 419. Nothing has to be added to your controllers:

use Marko\Routing\Attributes\Post;
class FormController
{
#[Post('/contact')]
public function submit(): Response
{
// Token already validated by the global CsrfMiddleware
return new Response('Submitted');
}
}

The middleware reads the token from the _token form field, the X-CSRF-TOKEN header, or the X-XSRF-TOKEN header. Safe methods (GET, HEAD, OPTIONS) are skipped.

It never runs on requests that match no route, because it does not carry #[RunsOnUnmatched] (see Which middleware runs). A POST to an unknown path gets a 404, and a POST to a GET-only path gets a 405 with Allow --- never a 419 token mismatch.

Webhook receivers, token-authenticated APIs and other routes that are not driven by a browser session opt out explicitly with #[WithoutMiddleware], on one route or on a whole controller:

use Marko\Routing\Attributes\Post;
use Marko\Routing\Attributes\WithoutMiddleware;
use Marko\Security\Middleware\CsrfMiddleware;
use Marko\Session\Middleware\SessionMiddleware;
#[WithoutMiddleware([SessionMiddleware::class, CsrfMiddleware::class])]
class StripeWebhookController
{
#[Post('/webhooks/stripe')]
public function handle(): Response
{
// Verify the provider's signature instead
}
}

A route that excludes SessionMiddleware but not CsrfMiddleware has no session to compare tokens against. A state-changing request to it throws CsrfSessionUnavailableException (a loud 500) that tells you to exempt the route, rather than rejecting every request with a misleading 419.

Issuing a token (CsrfTokenManagerInterface::get() the first time) writes it to the session. That counts as a modification, so the session is saved and the visitor gets a session cookie even under lazy session persistence. The form submission then carries the cookie that holds the token.

Include the token in forms:

use Marko\Security\Contracts\CsrfTokenManagerInterface;
readonly class ContactController
{
public function __construct(
private CsrfTokenManagerInterface $csrfTokenManager,
) {}
public function form(): Response
{
$token = $this->csrfTokenManager->get();
// Render form with <input type="hidden" name="_token" value="$token">
}
}

Whenever a response persists the session (the request resumed the visitor’s session, or modified it --- issuing a token counts), CsrfMiddleware mirrors the current token into an XSRF-TOKEN cookie. axios, and therefore Inertia, read that cookie and send it back as the X-XSRF-TOKEN header on every same-origin request, so SPA requests pass the CSRF check with no extra code.

AttributeValue
HttpOnlyoff --- JavaScript must read it
SameSiteLax
Secure, Path, Domainfollow the session cookie (session.cookie.* in config/session.php)
Lifetimebrowser session; re-issued whenever the token differs from the one the client sent

A request whose session is only read and then discarded (a cookieless visitor on a page that never issues a token) gets no XSRF-TOKEN cookie, so bots and health checks still create no sessions. If the first page of your SPA neither starts a session nor renders a form, issue the token there --- for example call $this->csrfTokenManager->get() in the controller that renders the SPA’s root view --- so the first POST already has a cookie to echo.

Installing marko/security adds protective headers to every response. Its module.php registers SecurityHeadersMiddleware as global middleware, outside CsrfMiddleware so a 419 carries the headers too. It declares #[RunsOnUnmatched], so 404 and 405 responses get them as well (see Which middleware runs).

Headers are configured in config/security.php under the headers key (see Configuration above):

  • An empty value omits that header.
  • A header the response already carries is left alone (names compared case-insensitively). A controller or inner middleware that sets its own Content-Security-Policy or X-Frame-Options keeps it, so one route can send a stricter or looser policy than the default.
  • Strict-Transport-Security is sent only on HTTPS responses: when the server reports HTTPS (anything but off), REQUEST_SCHEME is https, or the first X-Forwarded-Proto value is https (a TLS-terminating proxy). Browsers ignore HSTS on plain HTTP, so a forged X-Forwarded-Proto gains nothing.
  • X-XSS-Protection defaults to 0, which turns off the legacy browser XSS auditor. The old 1; mode=block value is deprecated and opened XS-Leak attacks in the browsers that still honoured it. Set the value to '' to omit the header.
use Marko\Routing\Attributes\Get;
use Marko\Routing\Http\Response;
class ReportController
{
#[Get('/reports/embed')]
public function embed(): Response
{
// Kept as-is: the global middleware only fills in headers that are missing
return new Response('<p>Report</p>', 200, [
'Content-Security-Policy' => "frame-ancestors https://partner.example.com",
]);
}
}

To drop the headers from a route entirely, exclude the middleware with #[WithoutMiddleware], on one route or a whole controller:

use Marko\Routing\Attributes\WithoutMiddleware;
use Marko\Security\Middleware\SecurityHeadersMiddleware;
#[WithoutMiddleware(SecurityHeadersMiddleware::class)]

When marko/authentication is installed, the token is regenerated on every LoginEvent and LogoutEvent (by the RotateCsrfTokenOnLogin and RotateCsrfTokenOnLogout observers). A token issued before login --- including one an attacker learned by planting a session from a sibling subdomain --- stops validating once the victim logs in, and the next person on a shared machine does not inherit the logged-out user’s token. Forms rendered before the login or logout must be reloaded to pick up the new token. Without marko/authentication these events are never dispatched, so nothing changes.

Regenerate tokens (e.g., after a privilege change of your own):

$newToken = $this->csrfTokenManager->regenerate();

Validate manually:

if (!$this->csrfTokenManager->validate($submittedToken)) {
// Invalid token
}

Tokens are generated using 32 random bytes encrypted via marko/encryption and stored in the session. The validate() method uses timing-safe comparison (hash_equals) to prevent timing attacks.

Replace CsrfTokenManager via Preferences to change token generation or storage:

use Marko\Core\Attributes\Preference;
use Marko\Security\CsrfTokenManager;
#[Preference(replaces: CsrfTokenManager::class)]
class MyCsrfTokenManager extends CsrfTokenManager
{
public function get(): string
{
// Custom token retrieval logic
}
}

CsrfMiddleware throws CsrfTokenMismatchException when validation fails. This exception extends SecurityException, which provides rich context and suggestions --- consistent with Marko’s loud-errors principle:

use Marko\Security\Exceptions\CsrfTokenMismatchException;
// Thrown automatically by CsrfMiddleware:
// message: "CSRF token validation failed."
// context: "The submitted CSRF token does not match the token stored in the session..."
// suggestion: "Ensure your form includes a valid CSRF token field (_token), or send it in the X-CSRF-TOKEN or X-XSRF-TOKEN header..."

CsrfTokenMismatchException implements Marko\Core\Exceptions\HttpExceptionInterface, so the routing pipeline renders it as 419 Page Expired with the body {"message": "CSRF token mismatch."} (JSON, or a minimal HTML page for browsers). The detailed message, context and suggestion above stay server-side. Because the response is rendered where the middleware threw, outer middleware such as CORS and security headers still decorate it --- see Errors and HTTP Exceptions.

use Marko\Security\Contracts\CsrfTokenManagerInterface;
public function get(): string; // Get token, generating one if none exists
public function validate(string $token): bool; // Validate against stored token
public function regenerate(): string; // Regenerate token, replacing the previous one
use Marko\Security\Middleware\CsrfMiddleware;
public const string COOKIE_NAME = 'XSRF-TOKEN';
public function __construct(
CsrfTokenManagerInterface $tokenManager,
SessionInterface $session,
SessionConfig $sessionConfig,
);
public function handle(Request $request, callable $next): Response;
use Marko\Security\CsrfTokenManager;
public function __construct(
SessionInterface $session,
EncryptorInterface $encryptor,
string $sessionKey = '_csrf_token', // module.php passes security.csrf.session_key
);
use Marko\Security\Middleware\SecurityHeadersMiddleware;
#[RunsOnUnmatched]
public function __construct(SecurityConfig $securityConfig);
public function handle(Request $request, callable $next): Response;
use Marko\Security\Config\SecurityConfig;
public function csrfSessionKey(): string;
public function headerXContentTypeOptions(): string;
public function headerXFrameOptions(): string;
public function headerXXssProtection(): string;
public function headerStrictTransportSecurity(): string;
public function headerReferrerPolicy(): string;
public function headerContentSecurityPolicy(): string;
use Marko\Security\Exceptions\SecurityException;
public function getContext(): string;
public function getSuggestion(): string;
use Marko\Security\Exceptions\CsrfTokenMismatchException;
public static function invalidToken(): self;
public function getStatusCode(): int; // 419
public function getHeaders(): array; // []
public function getResponseData(): array; // ['message' => 'CSRF token mismatch.']
use Marko\Security\Exceptions\CsrfSessionUnavailableException;
public static function forRequest(string $method, string $path): self;