In Laravel, how do URL::signedRoute and URL::temporarySignedRoute make an emailed unsubscribe link tamper-proof, and what exactly does the signature cover?
answer
- HMAC over the generated URL
- sha256 keyed by the app key
- query string included, method and user not
- expires is signed too
- signed middleware: 403 InvalidSignatureException
basics
~20 sURL::signedRoute appends a signature query parameter: an HMAC-SHA256, keyed by the application key, of the full generated URL including its query string. temporarySignedRoute also signs an expires timestamp. The signed middleware rejects edited or expired links with a 403.
solid answer
~40 s`URL::signedRoute('newsletter.unsubscribe', ['subscriber' => $id])` builds the route URL, then appends `signature`: `hash_hmac('sha256', $url, $appKey)` over the whole absolute URL — scheme, host, path and every query parameter. `URL::temporarySignedRoute($name, $expiration, $params)` takes the expiry as its **second** argument and adds an `expires` Unix timestamp before signing, so the deadline itself is tamper-proof. On the way in, the `signed` middleware (`ValidateSignature`) or `$request->hasValidSignature()` recomputes the HMAC from the incoming URL and compares it with `hash_equals`; a mismatch or a past `expires` makes the middleware throw `InvalidSignatureException`, a 403. The signature does **not** cover the HTTP method, the visitor or any server-side state: a valid link is a bearer token, reusable by anyone who holds it until it expires.
code
php · 17 lines<?php
use App\Models\Subscriber;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL;
Route::get('/newsletter/unsubscribe/{subscriber}', function (Subscriber $subscriber) {
return view('newsletter.confirm-unsubscribe', ['subscriber' => $subscriber]);
})->name('newsletter.unsubscribe')->middleware('signed');
// In the campaign mailable or job:
$link = URL::temporarySignedRoute(
'newsletter.unsubscribe',
now()->addDays(30),
['subscriber' => $subscriber->id],
);
// https://example.com/newsletter/unsubscribe/42?expires=...&signature=...go deeper
Recall the two methods, that the signature is a query parameter, and that the signed middleware turns a bad link into a 403.
Explain what is hashed: the full URL with its query string, keyed by the application key, with expires inside the signed string. Know the temporarySignedRoute argument order.
Point out the bearer-token and replay properties, when to prefer an expiry, how appended tracking parameters break links, and why links built in a queue worker depend on APP_URL.
Weigh signed links against stored one-time tokens: stateless and cheap versus revocable and single-use, and decide per action which risk the business accepts.
## The problem a signed link solves A marketing email carries an **unsubscribe link** that must work without a login: the recipient clicks it from a phone that has never seen the app. The naive link `https://example.com/newsletter/unsubscribe/42` is forgeable — anyone can change `42` to `43` and unsubscribe a stranger. Laravel's answer is a **signed URL**: the link carries a keyed hash of itself, and the server recomputes that hash on arrival. Change one character and the hashes no longer match. ## How the signature is built `URL::signedRoute($name, $parameters = [], $expiration = null, $absolute = true)` does four things, in this order: 1. Rejects a parameter literally named `signature` or `expires` with an `InvalidArgumentException` — both names are reserved. 2. If an expiration was given, adds `expires` as a Unix timestamp. The expiration may be a `DateTimeInterface`, a `DateInterval` or an integer number of seconds. 3. Sorts the parameters by key and generates the route URL with them (path segments filled, the rest as query string). 4. Computes `hash_hmac('sha256', $thatUrl, $key)` with the **application key** and appends it as `signature`. `URL::temporarySignedRoute($name, $expiration, $parameters = [])` is a thin wrapper that calls `signedRoute` with the expiry filled in. Note the argument order: the expiration comes **before** the parameters, unlike `signedRoute`, where it is third. ## What the signature covers — and what it does not | Covered by the HMAC | Not covered | |---|---| | Scheme and host (for the default absolute signature) | The HTTP method (GET vs POST) | | The path, including route parameters like `{subscriber}` | Who clicks the link; there is no user binding | | Every query parameter, including `expires` | Whether the link was already used | | | The URL fragment (`#...`), which never reaches the server | Three consequences follow: - **A signed link is a bearer token.** Anyone who holds it — a forwarded email, a shared screenshot — can use it until it expires. If the action is sensitive, keep the lifetime short or confirm identity separately. - **It is not single-use.** Nothing is stored server-side; replaying a link inside its window succeeds. One-shot semantics need your own record, such as a token column you null out. - **Any extra query parameter breaks it**, because the whole query string is hashed. A mail platform that appends `utm_source` to every link turns every unsubscribe click into a 403 unless those keys are explicitly ignored during validation. ## How validation works Two entry points share one implementation in `UrlGenerator::hasValidSignature()`: - The **`signed` middleware alias** (`Illuminate\Routing\Middleware\ValidateSignature`) on the route. On failure it throws `Illuminate\Routing\Exceptions\InvalidSignatureException`, an HTTP exception with status **403** and the message "Invalid signature." You can render a friendlier "link expired" page for it in `bootstrap/app.php`. - **`$request->hasValidSignature()`** inside the action, which returns a boolean and leaves the response to you. Validation rebuilds the URL from the **incoming request** — scheme, host, base path and path — plus the raw query string with `signature` removed, recomputes the HMAC, and compares with `hash_equals` (a constant-time comparison). The key list is the current application key plus any configured previous keys, so a key rotation does not instantly kill every link in flight. Only then does it check that `expires`, if present, is not in the past. ## Choosing between the two - **`signedRoute` without an expiry** fits a link that must keep working in an email archive for months, such as a newsletter unsubscribe, where the worst a leak can do is unsubscribe one address. - **`temporarySignedRoute`** fits anything more sensitive or time-boxed: a download link, a "confirm this change" link, a one-day guest invitation. Because the expiry is inside the signed string, a user cannot extend it by editing `expires` — that edit changes the hash. ## Practical notes - Generate the link where the URL root is right. A campaign sent from a queue worker has no browser request, so Laravel builds the URL from `APP_URL`; a wrong `APP_URL` produces links to the wrong host. - Mail security scanners sometimes prefetch links. Letting the GET show a confirmation page and doing the unsubscribe on a signed POST avoids accidental opt-outs. - Keep the signed route's action idempotent: a double click or a replay must not do harm.
- Why can't a user extend a temporary signed link by editing its expires parameter?Because `expires` is added to the parameters before the HMAC is computed, so it is part of the signed string. Changing it changes the URL that validation hashes, the recomputed signature no longer matches the one in the link, and the request fails with a 403 before the expiry is even checked.
- How would you make a Laravel signed link usable only once?The signature alone cannot do it: validation is stateless, so a replay inside the window passes. Add state — for example a nullable token or `used_at` column on the record, included as a signed parameter and cleared or stamped when the action runs. The action then rejects a link whose token no longer matches.
- Does a signed URL protect a POST form against cross-site request forgery?Not by itself. The signature proves the URL was issued by the app and not edited; it says nothing about who submitted the request or from which site. Anyone holding the link can trigger it from anywhere, so treat it as a bearer credential and keep forgery protection on routes that act on a logged-in session.
A signed link works like a bank draft: the bank's seal covers the payee and the amount, so altering either voids it, yet whoever holds the draft can present it until its date passes.
saying these in an interview costs you the question
- The signature only covers the route parameters, so extra query keys are harmless
- A signed link can only be used once
- Signed URLs are tied to the logged-in user who generated them
- Users can extend a temporary link by editing expires
- temporarySignedRoute takes the parameters before the expiration
- Laravel stores issued signatures in the cache to validate them