Why Signed Routes Deserve More Attention
Most Laravel developers reach for signed routes exactly once — the built-in email verification flow — and then forget they exist. That's a missed opportunity. Signed URLs are a lightweight, stateless alternative to tokens stored in a database, and they compose cleanly with Laravel's existing middleware stack.
This article focuses on the practical patterns that go beyond the defaults: custom signing keys per tenant, expiry strategies, invalidation via nonce, and wiring signed links into Filament table actions.
How Signing Actually Works
When you call URL::signedRoute() or URL::temporarySignedRoute(), Laravel appends a signature query parameter computed with HMAC-SHA256 over the full URL (including any expiry timestamp) using APP_KEY as the secret.
$link = URL::temporarySignedRoute(
'invoice.download',
now()->addMinutes(30),
['invoice' => $invoice->id]
);
The middleware signed (alias for ValidateSignature) rejects any request where the signature doesn't match or the expires timestamp has passed — no database round-trip required.
Per-Tenant Signing Keys
In a multi-tenant app you may want tenant A's signed links to be invalid on tenant B's subdomain. Laravel's URL::signedRoute() uses APP_KEY globally, but you can swap the key contextually by resolving a custom UrlGenerator or, more practically, by verifying the signature manually inside a custom middleware.
// app/Http/Middleware/ValidateTenantSignature.php
public function handle(Request $request, Closure $next): Response
{
$tenant = app('currentTenant');
$secret = $tenant->signing_secret; // stored per tenant
$expected = hash_hmac(
'sha256',
$request->fullUrlWithoutQuery() . '?'
. Arr::query(Arr::except($request->query(), 'signature')),
$secret
);
if (! hash_equals($expected, (string) $request->query('signature'))) {
abort(403, 'Invalid signature.');
}
if ($request->has('expires') && now()->timestamp > (int) $request->query('expires')) {
abort(403, 'Link expired.');
}
return $next($request);
}
Generate the link using the same HMAC logic in a dedicated action class so the signing logic lives in one place.
One-Time Links via Nonce Invalidation
Signed URLs are stateless, so they can be replayed until they expire. For truly one-time links (e.g., a magic login), combine a short expiry with a nonce stored in cache:
final class GenerateMagicLink
{
public function handle(User $user): string
{
$nonce = Str::uuid()->toString();
Cache::put("magic:{$nonce}", $user->id, now()->addMinutes(10));
return URL::temporarySignedRoute(
'auth.magic',
now()->addMinutes(10),
['nonce' => $nonce]
);
}
}
// In the controller
public function __invoke(Request $request): RedirectResponse
{
$request->validateSignature(); // throws if invalid/expired
$userId = Cache::pull("magic:{$request->nonce}"); // pull = get + delete
abort_if($userId === null, 403, 'Link already used.');
Auth::loginUsingId($userId);
return redirect()->intended('/dashboard');
}
Cache::pull() atomically retrieves and deletes the nonce, preventing replay without a separate "used" flag in the database.
Filament Table Action Integration
Filament's Action::url() accepts a closure, making signed links trivial to embed:
Tables\Actions\Action::make('download')
->label('Download Invoice')
->icon('heroicon-o-arrow-down-tray')
->url(fn (Invoice $record): string =>
URL::temporarySignedRoute(
'invoice.download',
now()->addHour(),
['invoice' => $record->id]
)
)
->openUrlInNewTab(),
Because the URL is generated server-side at render time, the signature is always fresh and scoped to the authenticated user's session context.
Protecting Signed Routes from Parameter Tampering
A subtle gotcha: if you add query parameters to a signed URL after generation (e.g., a UTM tag), the signature breaks. Teach consumers to append extra parameters before signing, or strip known analytics params in middleware before validation:
// In a custom ValidateSignature override
protected $except = ['utm_source', 'utm_medium', 'utm_campaign'];
Laravel's built-in ValidateSignature middleware accepts an $except property for exactly this purpose.
Takeaways
- Signed routes are stateless and require no token table — ideal for short-lived, low-stakes workflows.
- Per-tenant signing keys need a custom middleware; the built-in one always uses
APP_KEY. - Combine
Cache::pull()with a short expiry for true one-time links without a database migration. - Filament's
Action::url()closure makes signed link generation a one-liner in table definitions. - Strip analytics query params via
$exceptbefore signature validation to avoid false 403s.