A Laravel signed unsubscribe link validates locally but returns 403 Invalid signature in production; what are the likely causes, and how do signed:relative and hasValidSignatureWhileIgnoring help?
answer
- validation rebuilds the URL from the request
- scheme or host mismatch behind a proxy
- APP_URL for links built in workers
- absolute: false with signed:relative
- ignored keys become attacker-editable
basics
~20 sValidation rebuilds the URL from the incoming request, so any difference breaks the HMAC: a different scheme or host than at generation, or query keys appended in transit. Sign relative URLs with signed:relative, or ignore specific appended keys with hasValidSignatureWhileIgnoring.
solid answer
~40 sThe default signature is **absolute**: generation hashes the URL Laravel produced, and validation hashes `scheme + host + path + query` as the app sees the incoming request. Production breaks that in a few ways: the link was generated as `https` (by `URL::forceScheme('https')` or an `https` `APP_URL` in a queue worker) while the app, behind a TLS-terminating proxy it does not trust, sees `http`; the host differs; a mail platform appends `utm_*` keys; or a temporary link simply expired. Fixes: make the request report the real scheme, or sign with `absolute: false` and validate with `signed:relative` so only path and query are hashed. For known appended keys, use `$request->hasValidSignatureWhileIgnoring(['utm_source'])` or `ValidateSignature::except()`, remembering that ignored keys become freely editable.
go deeper
Remember that a signed link fails if anything in it changes, including the scheme, the host or an extra query parameter.
Explain that validation rebuilds the URL from the incoming request, and how absolute: false pairs with signed:relative.
Diagnose proxy scheme drift, APP_URL in workers and appended tracking keys by comparing the generated and received URLs, and ignore keys only when the action never trusts them.
Decide per product whether links should be origin-bound or relative, weighing phishing-resistance of a bound host against multi-domain and CDN deployments.
## Start from how validation works A Laravel signature is an HMAC over a string. Generation and validation must build **the same string**, or the check fails with `InvalidSignatureException` (a 403, "Invalid signature."). - **Generation** (`URL::signedRoute`, `URL::temporarySignedRoute`) hashes the URL the generator produced. Its scheme and host come from the current request, from `URL::forceScheme()` / `URL::forceHttps()` if set, and — in a console process such as a queue worker, where there is no browser request — from `APP_URL`. - **Validation** (`$request->hasValidSignature()`, the `signed` middleware) rebuilds the URL from the **incoming request**: `getSchemeAndHttpHost()`, the base URL and the path, plus the raw query string with only `signature` removed. Note the asymmetry: `forceScheme` changes what is **generated**, not how the incoming request is read. Any drift between the two sides is a 403. ## The usual production causes | Symptom | Cause | Fix | |---|---|---| | Every signed link fails, but only in production | Link signed as `https://`, request seen as `http://` because TLS ends at a proxy the app does not trust | Make the request report the real scheme (trusted proxy configuration), or sign relatively | | Links in queued emails point at the wrong host or fail | Worker built them from a wrong `APP_URL` | Set `APP_URL` to the public origin | | Only links from the email campaign fail | Mail platform appends `utm_source`, `utm_campaign` | Ignore those exact keys during validation | | Old links fail after a deploy | The application key changed without keeping the old one as a previous key | Keep old keys as previous keys during rotation | | Links fail after some days | A temporary link's `expires` passed | Longer lifetime, or no expiry for low-risk actions | | Links fail only from one mail client | A link-rewriting gateway re-encodes or reorders the query string | Relative signing does not help here; test the exact delivered URL | ## Relative signatures: `absolute: false` and `signed:relative` When the origin genuinely varies — several domains, a CDN in front, preview environments — hash only the path and query: ```php $link = URL::signedRoute('newsletter.unsubscribe', ['subscriber' => 42], absolute: false); Route::get('/newsletter/unsubscribe/{subscriber}', UnsubscribeController::class) ->name('newsletter.unsubscribe') ->middleware('signed:relative'); ``` The returned string is then a path (`/newsletter/unsubscribe/42?signature=...`); prefix it with the right origin when building the email. Both sides must agree: a relatively signed link checked by plain `signed` fails, because validation hashes the absolute form. The argument string is parsed literally — the first argument must be `relative`; any other word, such as `absolute`, is read as a query key to ignore. The static builders `ValidateSignature::relative()` and `ValidateSignature::absolute()` produce these strings for you. ## Ignoring appended query keys `$request->hasValidSignatureWhileIgnoring(['utm_source', 'utm_campaign'])` drops those keys from the query string before hashing. The middleware equivalents are extra arguments (`signed:relative,utm_source`) and the global `ValidateSignature::except(['utm_source'])`, usually called in a service provider. The docs warn about the price: **an ignored key is no longer protected**. Anyone can add or change it without breaking the signature. Ignore only keys the action never trusts — tracking tags, a pagination cursor — never an identifier or an amount. ## A diagnosis routine 1. Log the URL the app generated and the URL the request actually carries (`$request->fullUrl()`), side by side. 2. Compare scheme, host and port first; then compare the query string key by key. 3. If scheme or host differ, fix how the app sees the request or switch to relative signing. 4. If extra keys appear, decide whether to ignore them or stop the sender from adding them. 5. If the URLs match exactly, check `expires` against the server clock and whether the key changed. ## Why not just disable the check Dropping `signed` from an unsubscribe route turns `/newsletter/unsubscribe/43` into a tool for unsubscribing strangers. The signature is the only thing standing between a guessable id and a forged action, so the fix is always to align the two strings, not to stop comparing them.
- Why doesn't URL::forceScheme('https') on its own fix signed links behind a TLS-terminating proxy?It only changes generation. Links are signed as `https://…`, but validation rebuilds the URL from the incoming request's own scheme. If the app does not trust the proxy's forwarded headers, the request still looks like `http`, the hashed strings differ, and the check fails. Fix how the request reports its scheme, or sign relatively.
- When is ignoring a query key with hasValidSignatureWhileIgnoring dangerous?Whenever the action reads that key. Ignored keys are excluded from the hash, so a user can add or change them freely. Ignoring `utm_source` is harmless; ignoring `subscriber` or `list` would let anyone retarget a valid link at another record.
saying these in an interview costs you the question
- URL::forceScheme('https') also makes the incoming request validate as https
- Plain signed middleware accepts links signed with absolute: false
- Ignored query keys are still protected by the signature
- The signature depends on the visitor's IP address
- Just remove the signed middleware in production to stop the 403s