Laravel Signed Routes and Temporary URLs: Secure Link Patterns Beyond the Basics
#laravel #security #routing #filament

Laravel Signed Routes and Temporary URLs: Secure Link Patterns Beyond the Basics

4 min read Mohamed Said Mohamed Said

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.


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 $except before signature validation to avoid false 403s.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can a signed URL be invalidated before it expires?
Not natively — signed URLs are stateless. The standard pattern is to pair them with a cache-based nonce using Cache::pull(), which deletes the nonce on first use and effectively invalidates the link without touching the database.
Q02 Does adding UTM parameters to a signed URL break the signature?
Yes. Any change to the URL after signing invalidates the HMAC. Either include UTM params before signing, or list them in the $except array on the ValidateSignature middleware so they are ignored during verification.
Q03 Is it safe to embed signed URLs in emails?
Yes, with a short expiry. Use temporarySignedRoute() with an expiry appropriate to the action (e.g., 24 hours for email verification, 10 minutes for magic login). For sensitive actions, add the nonce pattern to prevent replay if the email is forwarded.

Continue reading

More Articles

View all