How Signed URLs Work Under the Hood
When you call URL::temporarySignedRoute() in Laravel 12, the framework appends two query parameters to your named route: expires and signature. The signature is an HMAC SHA-256 hash calculated over the complete target URL—including scheme, host, path, and all query parameters—using your application's APP_KEY secret defined in .env.
Because the HMAC includes the full URL string, changing a single character invalidates the signature. Tweaking a user ID parameter from 42 to 43 or modifying the expires timestamp immediately breaks the hash. When a request hits your route, Laravel's Illuminate\Routing\Middleware\ValidateSignature middleware recalculates the hash from the incoming URL. If the hash doesn't match or the timestamp has passed, Laravel throws an InvalidSignatureException, yielding a 403 HTTP status code.
Defining and Validating Temporary Routes
To set up a temporary link, start with a named route in routes/web.php or routes/api.php. Attach the signed middleware directly to the route definition or handle signature validation manually inside your controller.
use App\Http\Controllers\ReportDownloadController;
use Illuminate\Support\Facades\Route;
Route::get('/downloads/report/{report}', [ReportDownloadController::class, 'show'])
->name('reports.download')
->middleware('signed');Generating the signed link requires the route name, an expiration timestamp, and route parameters:
use Illuminate\Support\Facades\URL;
$downloadUrl = URL::temporarySignedRoute(
'reports.download',
now()->addMinutes(15),
['report' => $report->id]
);If you build single-page applications or render pages via Next.js 16, sending default HTML 403 pages on signature failure complicates error handling. Validating the signature manually inside your controller gives you full control over response codes and JSON error structures.
The Proxy and Scheme Mismatch Trap
The most common bug with a laravel signed url in production happens when running behind a reverse proxy like Nginx, AWS Application Load Balancer, or Cloudflare. Your frontend issues HTTPS requests, but if your Laravel container terminates TLS at the load balancer and receives plain HTTP internally, Laravel generates signed URLs starting with http://.
When the user clicks the link, their browser upgrades it to https:// or your proxy redirects it. Because Laravel calculates the signature over the entire string including http://, the signature validation fails on the incoming https:// request. The HMAC check compares http://example.com/download?... against https://example.com/download?... and fails with HTTP 403.
Fix this issue in Laravel 12 by configuring trusted proxies inside bootstrap/app.php:
use Illuminate\Http\Request;
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '*');
})Always verify that APP_URL in .env starts with https:// in production environments. If APP_URL points to http://localhost while production users access https://app.example.com, generated signatures will fail every time.
Why Standard Signed URLs Aren't One-Time Links
A core limitation of Laravel's built-in signed routes is that they're time-limited, not access-limited. A URL signed for 15 minutes can be accessed once or ten thousand times until the expiration timestamp passes. If a user receives a download link and forwards it to colleagues, anyone with the link can download the file within that active time window.
For paid digital products or confidential exports, you need single-use links. Once consumed, the link must expire immediately regardless of remaining time on the timestamp signature.
Building One-Time Download Links with Redis
To enforce single-use execution, combine signature validation with atomic cache operations in Redis. When generating the URL, attach a unique single-use token parameter. Upon receipt, verify that the token hasn't been claimed yet.
Here is a complete controller implementation using PHP 8.3 and Laravel 12 that validates the signature, guarantees single-use execution, and streams the file securely:
namespace App\Http\Controllers;
use App\Models\Report;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpFoundation\StreamedResponse;
class OneTimeDownloadController extends Controller
{
public function generateLink(Report $report): string
{
$token = bin2hex(random_bytes(16));
return URL::temporarySignedRoute(
'reports.one-time-download',
now()->addMinutes(10),
[
'report' => $report->id,
'token' => $token,
]
);
}
public function download(Request $request, Report $report): StreamedResponse
{
if (! $request->hasValidSignature()) {
abort(403, 'Invalid or expired signature.');
}
$token = $request->query('token');
$cacheKey = "download_token:{$token}";
$claimed = Cache::add($cacheKey, true, now()->addMinutes(15));
if (! $claimed) {
abort(410, 'This download link has already been used.');
}
$filePath = "reports/{$report->file_path}";
if (! Storage::disk('private')->exists($filePath)) {
abort(404, 'Requested file not found.');
}
return Storage::disk('private')->download(
$filePath,
$report->file_name
);
}
}The atomic mechanism relies on Cache::add(), which corresponds to Redis SETNX. The first request stores the key and returns true. Any duplicate request with the exact same token returns false, aborting immediately with HTTP 410 Gone.
Consuming Links from Next.js 16 and React 19
When connecting a React 19 frontend to your API, don't fetch one-time download endpoints using background AJAX requests if your goal is triggering a standard browser file download. Browsers won't open a save dialog from background fetch calls without loading the complete payload into JavaScript memory first.
Direct navigation or setting window.location.href works best for binary downloads. Beware of link prefetching: automatic browser pre-rendering triggers the endpoint early and burns the single-use cache token before the user clicks.
Here is a React 19 client component that fetches a fresh signed URL on demand and safely initiates the download:
'use client';
import { useState } from 'react';
interface DownloadButtonProps {
reportId: number;
}
export function DownloadButton({ reportId }: DownloadButtonProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDownload() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/reports/${reportId}/download-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to generate download link.');
}
const { url } = await response.json();
window.location.href = url;
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('An unexpected error occurred.');
}
} finally {
setLoading(false);
}
}
return (
<div className="flex flex-col gap-2">
<button
onClick={handleDownload}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{loading ? 'Preparing File...' : 'Download Report'}
</button>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
);
}Handling Key Rotations
Because signatures rely on APP_KEY, rotating application keys in production invalidates all active signed URLs. Users clicking 24-hour verification links generated before key rotation will hit 403 errors.
If key rotation happens frequently in your infrastructure, sign long-lived links with a dedicated secret key stored outside APP_KEY. For short-lived single-use links expiring in 10 minutes, scheduling key rotation during off-peak windows minimizes impacted operations.













