enumag/csrf-route-bundle
enumag/csrf-route-bundle (GenedysCsrfRouteBundle) secures Symfony 3 routes against CSRF attacks without forms. Add a csrf_token option to any route; the bundle's router appends a token query parameter and a controller listener validates it on incoming requests.
Step 0 — read the right section first. Open the README and skip ## Installation entirely. The install instructions (composer require genedys/csrf-route-bundle, register Genedys\CsrfRouteBundle in bundles.php) are Symfony-only and will not work in a Laravel app; note also that the install target (genedys/...) does not match the repository name (enumag/csrf-route-bundle). The sections that matter to you are Options configuration and Twig integration — they describe the behavior you are going to reproduce, not code you will run.
Step 1 — extract the contract. The bundle does exactly three things, and all three have direct Laravel equivalents:
| Bundle concept | Laravel equivalent |
|---|---|
Route option csrf_token: true / {token, intention, methods} |
Route defaults() or ->middleware() on the route/group |
Custom router appends ?_token=... on generation |
A route_csrf() helper wrapping route() |
| Controller listener validates token before the action | A RouteCsrfToken middleware in the web group |
field_name: _token config |
config('app.csrf_field') |
enabled: true/false |
Env-driven kill switch in the middleware |
intention (defaults to route name) |
Route name + key params hashed into the token payload |
@CsrfToken annotation |
No analog — use route defaults; Laravel has no annotation reader |
Step 2 — pick your first use case. The canonical one: a destructive link in an admin panel that does not go through a form.
// routes/web.php — currently unprotected, GET-triggered state change
Route::get('/admin/posts/{post}/delete', [PostController::class, 'destroy'])
->name('admin.posts.delete');
That is the gap the bundle exists to close. Everything below protects this route without adding a <form>.
Step 3 — the minimum viable implementation (~40 lines, no dependency):
// app/Support/RouteCsrf.php
class RouteCsrf
{
public static function token(string $intention): string
{
return hash_hmac('sha256', $intention, session()->token());
}
public static function url(string $name, array $params = [], array $query = []): string
{
$intention = $name.'|'.http_build_query($params);
return route($name, $params + ['_token' => self::token($intention)]);
}
}
// app/Http/Middleware/RouteCsrfToken.php
public function handle(Request $request, Closure $next, ?string $intention = null)
{
if (! config('app.route_csrf_enabled', true)) {
return $next($request);
}
$intention ??= $request->route()->getName().'|'.http_build_query($request->route()->parameters());
$provided = (string) $request->input('_token', $request->header('X-Route-CSRF'));
if (! hash_equals(RouteCsrf::token($intention), $provided)) {
abort(419, 'CSRF token mismatch.');
}
return $next($request);
}
Route::get('/admin/posts/{post}/delete', [PostController::class, 'destroy'])
->name('admin.posts.delete')
->middleware('route.csrf');
Step 4 — wire the link. In Blade: <a href="{{ \App\Support\RouteCsrf::url('admin.posts.delete', ['post' => $post]) }}">Delete</a>. Confirm it 419s when you strip the query string, and passes when you keep it. That is your first closed gap.
Opt-in at the route, not at the group. The bundle's design is per-route csrf_token options, and that is the right default for a Laravel port too. Group-level middleware is tempting but silently covers routes you add later — including idempotent ones where a stale bookmark should not hard-fail. Reserve group middleware for a ->prefix('admin/destructive') sub-group you control end to end.
Route::prefix('admin')->name('admin.')->middleware('route.csrf')->group(function () {
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->name('posts.destroy');
});
Separate intentions per action and per record. The bundle defaults intention to null, which resolves to the route name — meaning one token is valid for deleting any post. Bind the record key in:
RouteCsrf::url('admin.posts.delete', ['post' => $post->id]);
// intention becomes "admin.posts.delete|post=42"
This is the single highest-value deviation from the bundle's defaults. A leaked token then authorizes one record, not the whole collection.
Respect HTTP methods. The bundle's methods: [GET] default is a footgun (see Gotchas). In Laravel, prefer DELETE/POST with a token in the body, and only fall back to a GET link when you genuinely cannot change the verb:
Route::delete('/admin/posts/{post}', ...)->middleware(['route.csrf', 'can:delete,post']);
If it must stay GET, add a form-based fallback (@method('DELETE')) for anything reachable by a crawler or a mail-client prefetcher.
XHR and fetch flows. Laravel's VerifyCsrfToken already handles X-XSRF-TOKEN for same-origin XHR via the XSRF-TOKEN cookie. Do not duplicate that. Use this middleware only for the routes outside that surface — e.g. an authenticated API-ish endpoint consumed by a legacy jQuery admin. Read the header in the middleware (as shown above) and send it from the client:
fetch('/admin/posts/42/archive', { headers: { 'X-Route-CSRF': window.routeCsrfToken } });
Token rotation and lifetime. Mirror the session-bound HMAC but rotate the secret when privilege changes: session()->regenerate() on login/logout already invalidates every derived token because session()->token() changes. That gives you "rotate on privilege change" for free — document it, because an auditor will ask.
Blade directive for the link. Wrap the helper so views stay readable and you have one grep target:
// AppServiceProvider::boot()
Blade::directive('csrfUrl', fn ($expr) => "<?php echo e(\\App\\Support\\RouteCsrf::url({$expr})); ?>");
<a href="@csrfUrl('admin.posts.delete', ['post' => $post])">Delete</a>
Prefer signed URLs where they fit. For pure GET links, Laravel's URL::temporarySignedRoute() gives you tamper-proof, expiring, stateless URLs with zero middleware — and no session coupling. Use it when the link is the only authorization surface; use the HMAC middleware when you want the token tied to the user's session so logout kills it.
Testing pattern. Two tests per protected route: a passing one and a 419 one. Use $this->withSession([]) and assert assertStatus(419) after stripping _token. For feature tests that go through many unrelated routes, $this->withoutMiddleware(RouteCsrfToken::class) is cleaner than threading tokens through every call.
Migration path off form-driven flows. The concrete value case: you are converting a form POST to a fetch call and don't want to lose the guarantee. Keep VerifyCsrfToken for the form path, and add the route middleware to the fetch path as a bridge, then delete it once the client sends X-XSRF-TOKEN.
You cannot install this. Symfony 3 bundle, no service provider, no facade, no Laravel middleware, and Symfony 3.4 hit EOL in November 2021. composer require genedys/csrf-route-bundle in a Laravel app will either fail resolution or pull unpatched transitive framework code into a security-critical path. Treat the README as a specification document, full stop.
Token-in-URL leaks. Every token this pattern emits lands in browser history, Referer headers to any third-party asset on the redirect target, reverse-proxy access logs, and shared Slack pastes. Mitigations, all of which you should ship on day one: Referrer-Policy: same-origin (Laravel ships strict-origin-when-cross-origin by default — verify), Cache-Control: no-store on the response, short token TTL, and single-use tokens (delete the intention from the session after successful validation if you need strictness — the bundle does not do this).
The bundle's methods: [GET] default is backwards. It validates the token on GET, which means a prefetcher or a browser preloading the link burns the token and the user's actual click 419s. If you keep GET-triggered actions, make the token multi-use or bind it to the record rather than the request. Note the README's methods option only lists [GET] — confirm whether your port should accept DELETE/POST too, since that is where the real protection lives.
The bundle never states constant-time comparison. hash_equals is non-negotiable in your port; a naive === on an HMAC is a timing oracle. Same for hash_hmac over md5/uniqid.
Intention collisions. intention: null → route name means one token authorizes every record on that route. Always interpolate record identifiers into the intention string. Also beware route names containing | — hash the serialized payload, not a hand-glued string, or use json_encode([$name, $params]) as the message.
Ordering and overrides. The bundle registers a router adapter and must be loaded after any other router-overriding bundle. The Laravel analog: middleware priority. Register RouteCsrfToken before SubstituteBindings is not required (you need the bound model, actually) — but do ensure it runs before your controller and after StartSession, otherwise session()->token() is empty and every request 419s with a confusing message.
Route caching. If you implement opt-in via a closure in route defaults() (e.g. ->defaults('csrf', fn () => true)), php artisan route:cache will throw. Use scalar defaults (->defaults('csrf', 'admin.posts.delete')) or plain middleware names, and run route:cache in CI to catch it.
The config kill switch is a live grenade. The bundle's enabled: false disables all verification globally. If you replicate that, gate it on an env var that is absent in production, and log a loud warning when it flips — an accidental ROUTE_CSRF_ENABLED=false silently disables a security control across the whole app.
Debugging 419s. Laravel's VerifyCsrfToken also returns 419, so you cannot tell which layer rejected the request. Give your middleware a distinct message ('Route CSRF token mismatch.') and log the intention plus the route name on failure — never the token itself. Check in this order: session cookie present → intention string matches exactly (trailing slash, param order, and http_build_query encoding are common culprits) → hash_equals argument order (known, user).
Package identity drift. Repo is enumag/csrf-route-bundle, README install target is genedys/csrf-route-bundle, original author credited to Genedys. Zero stars, zero dependents, README-only maturity. This is a fork of an abandoned bundle — relevant if you were tempted to composer require it, and worth mentioning in the ticket that closes your native implementation, so nobody re-litigates it later.
Where to extend if you port further. The two natural extension points beyond the middleware are (1) a route:list column or custom Artisan command that inventories every state-mutating route and flags unprotected ones — this is the audit artifact that makes the control defensible; and (2) an event or log channel on token rejection so you can alert on spikes rather than discover them from users.
How can I help you explore Laravel packages today?