zenstruck/signed-url-bundle
Symfony bundle for generating and validating tamper-proof signed URLs. Create URLs with optional expiration and validate them via a request listener, keeping downloads, password resets, and other sensitive actions secure and stateless.
First, calibrate expectations: zenstruck/signed-url-bundle is a Symfony bundle, not a Laravel package. It ships no service provider, no config/signed-url.php, and no facade. It wraps Symfony's UriSigner to give you tamper-proof URLs with an optional expiry. If you are working in a Laravel codebase, the built-in URL::signedRoute(), URL::temporarySignedRoute() and the signed / signed:relative middleware already cover this ground and should be your default. Read this package for the patterns (expiry-in-URL, HMAC-over-full-URI, verify-on-entry) or when you are maintaining a Symfony service that shares URLs with a Laravel app.
Install and register:
composer require zenstruck/signed-url-bundle
// config/bundles.php
return [
// ...
Zenstruck\SignedUrl\ZenstruckSignedUrlBundle::class => ['all' => true],
];
# config/packages/zenstruck_signed_url.yaml
zenstruck_signed_url:
signing_key: '%env(APP_SIGNING_KEY)%' # defaults to %kernel.secret% if omitted
Look first at three things: the SignedUrlGenerator service (sign(), signRoute(), verify()), the Twig functions (signed_url(), signed_path()), and Symfony's UriSigner itself — all the crypto lives there, the bundle is thin glue.
Your first use case is almost always an expiring download or "magic link":
public function download(SignedUrlGenerator $generator): Response
{
$url = $generator->sign('/download/123', '+30 minutes');
// e-mail or return this url
}
And the receiving side:
public function receive(Request $request, SignedUrlGenerator $generator): Response
{
if (!$generator->verify($request->getRequestUri())) {
throw new AccessDeniedHttpException();
}
// ...
}
Controller injection over static calls. The generator is a normal service; type-hint it in the controller action or constructor. Do not reach for a global helper — there isn't one.
Route signing beats string building. signRoute('invoice.download', ['invoice' => 42], '+1 hour') uses the router to build the URI before signing it, so renames and parameter changes stay consistent. Hand-concatenating a string and signing it is how you end up with links that pass on staging and fail in production because of a trailing slash.
Twig for links in emails/views. {{ signed_path('unsubscribe', {user: user.id}) }} keeps signing out of templates' surrounding logic. Generate the URL at render time, not at persistence time — a signed URL stored in a database row will expire before it is ever sent.
Verify at the boundary, then authorize. Signature verification answers "was this URL produced by someone holding the key", not "is this user allowed". Always follow verify() with your normal authorization check (ownership of the invoice, membership in the tenant). Signed URLs are a shortcut around login, which means a leaked inbox equals access.
Expiry strings are relative dates. '+1 hour', '+30 days', or null for non-expiring. Pass null deliberately for permanent links (canonical share URLs) and an explicit TTL everywhere else.
Testing. Set a fixed signing_key in config/packages/test/ so signatures are deterministic across runs, and freeze the clock (Symfony's ClockMock, or Carbon in a Laravel port) when asserting that an expired link is rejected. Two tests worth having from day one: "an unsigned URL is rejected" and "a URL with a tampered query param is rejected".
Laravel translation table, if you are porting the concept rather than the package:
| Symfony bundle | Laravel equivalent |
|---|---|
$generator->sign($url, '+1 hour') |
URL::temporarySignedRoute($name, now()->addHour(), $params) |
$generator->signRoute($name, $params) |
URL::signedRoute($name, $params) |
$generator->verify($uri) |
$request->hasValidSignature() |
| manual check in controller | ->middleware('signed') |
Twig signed_url() |
URL::signedRoute() in the Blade template |
The signing key is an operational dependency, not a config detail. If you rotate APP_SIGNING_KEY (or let it default to kernel.secret, which you also rotate), every outstanding signed link dies at once — queued emails, in-flight password resets, printed QR codes. Treat it as long-lived shared state, deploy it to every worker and web node simultaneously, and note that early versions have no multi-key rotation, so dual-key acceptance means writing a small verifier wrapper yourself.
The signature covers the exact URI. Add a utm_source param, reorder query keys, or change http to https and verification fails. If analytics tooling is going to append parameters, sign a canonical subset or route the link through a redirect that strips them before verification.
Proxies and hosts are the classic production-only failure. Behind a load balancer, $request->getRequestUri() may be built from X-Forwarded-* headers that differ from what you signed. Configure trusted_proxies / trusted_hosts correctly, or prefer signing relative paths and rebuilding the absolute URL with the same host on both ends.
Clock skew silently breaks expiry. Expiry is encoded in the URL (an expires parameter) and compared against the verifying server's time. Keep NTP on, and give short-lived links a minute of slack rather than debugging phantom 403s.
verify() returns a boolean and never throws. It is easy to write $generator->verify($uri); as a bare statement and ship a link that accepts anything. Lint for it; in Laravel the equivalent mistake is forgetting that hasValidSignature() returns false rather than aborting.
No built-in revocation. Signatures are stateless — a link cannot be invalidated before its expiry except by rotating the key. For "revoke this download link", you need a database-backed single-use token instead. Use signed URLs for read-only or idempotent actions, and pair them with an opaque token for one-shot operations.
Extension point is the generator service. If you need custom TTL defaults or a different signature payload, decorate SignedUrlGenerator in services.yaml rather than forking the bundle; the underlying UriSigner is also replaceable if you need a different hashing algorithm.
How can I help you explore Laravel packages today?