Laravel Signed URLs: Stop Sending Naked Links 🔐
Laravel Signed URLs: Stop Sending Naked Links 🔐
You spent three days building a "secure" file download feature. You slapped an auth middleware on the route, felt proud of yourself, and shipped it.
Then a user copies the download link, pastes it in Slack, and suddenly the whole company can download private invoices — logged out, no account needed, forever.
Welcome to the world of "naked links." They're exposed, they don't expire, and they're silently leaking your data right now.
What Even Is a Signed URL? 🤔
A signed URL is a regular URL with a cryptographic signature appended to it. Laravel generates the signature using your APP_KEY, so nobody can tamper with it without you knowing.
It can also have an expiry time baked in. After that, it's dead. Gone. Useless. Like my motivation after a 3-hour meeting.
Without signed URLs:
https://app.com/invoices/download/42
Anyone who gets this link can download invoice #42. Forever. No questions asked.
With a signed URL:
https://app.com/invoices/download/42?expires=1708531200&signature=abc123...
This link expires in 24 hours. Tamper with it? Laravel rejects it. Share it? It dies on schedule.
The Basics: Two Lines of Code 🎯
// Permanent signed URL (tamper-proof, never expires)
$url = URL::signedRoute('invoice.download', ['invoice' => 42]);
// Temporary signed URL (tamper-proof AND expires)
$url = URL::temporarySignedRoute(
'invoice.download',
now()->addHours(24),
['invoice' => 42]
);
And on the route side, protecting it is just as simple:
Route::get('/invoices/download/{invoice}', [InvoiceController::class, 'download'])
->name('invoice.download')
->middleware('signed');
That signed middleware does all the validation. Invalid signature? 403. Expired? 403. Tampered URL? 403. It's beautiful.
Real Talk: Where I Actually Use This 💼
In production systems I've built at Cubet, signed URLs show up in three places constantly.
1. File downloads that shouldn't live forever
When a customer purchases something and needs a one-time download link:
public function sendDownloadLink(Order $order): void
{
$url = URL::temporarySignedRoute(
'order.download',
now()->addHours(48),
['order' => $order->id]
);
$order->user->notify(new OrderReadyNotification($url));
}
The customer gets 48 hours. After that, they come back to their dashboard to generate a fresh link. Clean. Auditable. No "forever links" floating around.
2. Email verification and one-time actions
Laravel already uses this under the hood for email verification — but you can hook into the same pattern for any "click once, do the thing" flow:
// In a controller or service
$url = URL::temporarySignedRoute(
'subscription.cancel',
now()->addHours(2),
['user' => $user->id]
);
Mail::to($user)->send(new CancellationConfirmationMail($url));
Now even if someone intercepts the email, the window is 2 hours. Beats a plain /cancel?user=42 link that works forever.
3. Sharing resources with external parties
Contractors, auditors, third-party integrations — sometimes you need to give someone temporary access without creating them an account. Signed URLs are perfect here.
$reportUrl = URL::temporarySignedRoute(
'report.view',
now()->addDays(7),
['report' => $report->id, 'client' => $client->id]
);
Seven days, then it expires automatically. No cleanup job needed, no "oops I forgot to revoke access."
Pro Tip: Validate in the Controller Too 🛡️
The signed middleware handles the heavy lifting, but sometimes you want extra context — like logging who accessed what:
public function download(Request $request, Invoice $invoice): Response
{
if (! $request->hasValidSignature()) {
abort(403, 'This link has expired or is invalid.');
}
// Log the access for auditing
activity()
->performedOn($invoice)
->log('downloaded via signed URL');
return Storage::download($invoice->file_path);
}
As a Technical Lead, I've learned that audit trails for sensitive downloads aren't optional — they're "why didn't we have this when the lawyer called" territory.
The Expiry Gotcha Nobody Tells You About ⚠️
Here's something that burned us early on: signed URL expiry is checked against server time, not client time. If your servers have clock drift (even a few seconds), valid URLs can appear expired.
Fix: make sure your servers are syncing with NTP. In AWS, this is automatic on EC2. On other setups, double-check:
timedatectl status
# Should show NTP synchronized: yes
Also, give your expiry times a little breathing room. A "1-hour" link that actually needs to survive email delivery and user response time should probably be 2-4 hours in practice.
Revocation: The One Thing Signed URLs Can't Do 🚫
Here's the honest limitation nobody likes to admit: you can't revoke a signed URL before it expires.
The signature is cryptographically valid until the expiry time. If a URL leaks with a 7-day window, those 7 days are happening.
The pattern I use in production:
// Add a "generation ID" to the signed URL
$url = URL::temporarySignedRoute(
'document.download',
now()->addDays(7),
['document' => $doc->id, 'gen' => $doc->link_generation]
);
// In the controller, validate the generation ID
public function download(Document $document, int $gen): Response
{
if ($document->link_generation !== $gen) {
abort(403, 'This link has been revoked.');
}
// ...
}
To "revoke" all existing links, just increment link_generation on the document. All old URLs now fail the generation check. Problem solved.
A pattern that saved us in a real project: a customer accidentally sent a signed document link to the wrong email thread. We incremented the generation ID, sent a fresh link to the right recipient, and the leaked link died immediately — without waiting for expiry.
Bonus Tips 🎁
Test that your URLs actually expire:
// In your feature tests
$url = URL::temporarySignedRoute('route', now()->subHour(), ['id' => 1]);
$this->get($url)->assertStatus(403); // Should be dead
Use hasValidSignatureWhileIgnoring() for extra query params:
Sometimes you add UTM parameters or tracking data to URLs. Don't let that break your signature validation:
if ($request->hasValidSignatureWhileIgnoring(['utm_source', 'utm_campaign'])) {
// UTM params are ignored during signature check
}
Custom error pages: The default 403 from the signed middleware is a bit cold. Customize it in your exception handler to give users a "this link has expired, here's how to get a new one" message. Users appreciate not being abandoned at a dead link.
TL;DR — The 30-Second Version ⚡
URL::signedRoute()— tamper-proof, permanent linkURL::temporarySignedRoute()— tamper-proof + expires- Add
->middleware('signed')to protect the route - For revocation, use a generation ID column
- Give expiry times breathing room for real-world usage
Stop sending links that live forever. Your users' data deserves better than a plain URL with no expiry. Signed URLs take 10 minutes to implement and they prevent the kind of "oops, that link was in a forwarded email" conversation you really don't want to have.
Learned something? Find me on LinkedIn — I occasionally post about Laravel patterns that have saved (or nearly destroyed) real production systems. 😄
More Laravel deep dives on GitHub — the whole blog is open source, feel free to steal ideas!
Now go audit every download route in your app. I'll wait. 🔍