spatie/url-signer
Generate and verify signed URLs with expiration timestamps using a shared secret. spatie/url-signer appends expires and signature parameters, letting you safely share time-limited links (e.g., in emails) and validate them server-side with a simple API.
Installation:
composer require spatie/url-signer
Register the service provider in config/app.php (if not auto-discovered):
Spatie\UrlSigner\UrlSignerServiceProvider::class,
Configuration: Publish the config file:
php artisan vendor:publish --provider="Spatie\UrlSigner\UrlSignerServiceProvider"
Update config/url-signer.php with your signing key (e.g., APP_KEY or a custom string).
First Use Case: Generate a signed URL for a download link (e.g., a PDF):
use Spatie\UrlSigner\Facades\UrlSigner;
$signedUrl = UrlSigner::sign(
route('download.pdf', ['id' => 123]),
now()->addMinutes(15) // Expires in 15 minutes
);
Output: https://app.com/download/123?expires=1705000000&signature=abc123...
Signed Routes: Use named routes for clarity and maintainability:
$signedUrl = UrlSigner::sign(
route('admin.export', ['user' => $user->id]),
now()->addHours(1)
);
Dynamic Expiry Logic: Tie expiry to user roles or actions:
$expiry = $user->isAdmin() ? now()->addDays(7) : now()->addHours(1);
$signedUrl = UrlSigner::sign(route('user.profile'), $expiry);
Validation Middleware: Protect routes with a middleware:
namespace App\Http\Middleware;
use Closure;
use Spatie\UrlSigner\Facades\UrlSigner;
class ValidateSignedUrl
{
public function handle($request, Closure $next)
{
if (!$request->has('signature') || !$request->has('expires')) {
abort(403);
}
if (!UrlSigner::validate($request->fullUrl())) {
abort(403);
}
return $next($request);
}
}
Register in app/Http/Kernel.php:
protected $routeMiddleware = [
'signed.url' => \App\Http\Middleware\ValidateSignedUrl::class,
];
Usage in routes:
Route::get('/download/{id}', function () { ... })
->middleware('signed.url');
Signed URL Storage: Store signed URLs in the database (e.g., for email campaigns):
$user->signed_urls()->create([
'url' => route('user.invoice', ['id' => $invoice->id]),
'expires_at' => now()->addDays(3),
'signature' => UrlSigner::sign(
route('user.invoice', ['id' => $invoice->id]),
now()->addDays(3)
),
]);
Custom Signers: Extend for custom logic (e.g., HMAC-SHA1):
use Spatie\UrlSigner\Signers\Signer;
class CustomSigner implements Signer
{
public function sign(string $url, string $key, string $expiry): string
{
return hash_hmac('sha1', $url . $expiry, $key);
}
}
Bind in AppServiceProvider:
UrlSigner::extend('custom', function () {
return new CustomSigner();
});
Usage:
$signedUrl = UrlSigner::signWith('custom', $url, $expiry);
Laravel Notifications: Attach signed URLs to emails:
$email = new DownloadNotification($user, $signedUrl);
Mail::to($user)->send($email);
Queue Jobs: Generate signed URLs asynchronously:
GenerateSignedUrl::dispatch($user, $expiry);
Job:
public function handle()
{
$signedUrl = UrlSigner::sign(route('download'), $this->expiry);
// Send via email/notification
}
API Tokens: Combine with Sanctum/Passport for API access:
$token = $user->createToken('Signed URL Access')->plainTextToken;
$signedUrl = UrlSigner::sign(
"https://api.app.com/protected?token={$token}",
now()->addMinutes(5)
);
Clock Skew:
if (strtotime($request->expires) > now()->addMinutes(2)->timestamp) {
// Valid
}
URL Length Limits:
$key = base64_encode(hash('sha256', config('app.key'), true));
Query String Collisions:
expires/signature.#expires=...):
$url = route('download', [], false) . '?file=123#expires=...';
CSRF Protection:
Route::middleware('signed.url')->group(function () {
Route::get('/download', ...);
});
Key Rotation:
APP_KEY_v1) and migrate gradually.Validation Failures:
$url = $request->fullUrl();
$computedSig = UrlSigner::sign($url, config('url-signer.key'), $request->expires);
logger("Expected: {$request->signature}, Computed: {$computedSig}");
Timezone Issues:
expires uses UTC timestamps:
$expiry = now()->setTimezone('UTC')->addMinutes(10);
Signature Mismatches:
$originalUrl = route('download', ['id' => 123], false);
$signedUrl = UrlSigner::sign($originalUrl, $expiry);
Custom Query Parameters: Add metadata to signed URLs:
$signedUrl = UrlSigner::sign(
route('download'),
now()->addHours(1),
['user_id' => $user->id, 'purpose' => 'invoice']
);
Extend the UrlSigner class to support custom params.
Rate Limiting: Track signed URL usage to prevent abuse:
if (SignedUrl::where('signature', $request->signature)->exists()) {
abort(403, 'URL already used');
}
IP Restriction: Combine with middleware to restrict by IP:
public function handle($request, Closure $next)
{
if (!UrlSigner::validate($request->fullUrl()) ||
!$request->ip()->is($request->user()->allowed_ips)) {
abort(403);
}
return $next($request);
}
Event Dispatching: Trigger events on URL validation:
event(new SignedUrlValidated($request->signature));
Listen for analytics or logging:
SignedUrlValidated::listen(function ($event) {
Analytics::log('signed_url_used', ['signature' => $event->signature]);
});
Fallback URLs: Redirect expired URLs to a fallback:
if (!UrlSigner::validate($request->fullUrl())) {
return redirect()->route('download.fallback');
}
How can I help you explore Laravel packages today?