Single-Use Signed URLs Are Coming to Symfony's UriSigner
Symfony 8.2 adds a $version argument to UriSigner so a signed URL invalidates itself after one use. A clean native fix for password reset and login links.
If you have ever built a password reset flow, you already know the awkward part. You generate a signed link, email it, and the signature proves the URL was not tampered with. But nothing stops that same link from being used twice. The user clicks it, resets their password, and the link still verifies happily an hour later if someone digs it out of their inbox or a mail server log. The signature was never the problem. The problem is that a signature says “this URL is authentic,” not “this URL has not been spent yet.”
Symfony’s answer, merged into the development branch in late June 2026 and slated for Symfony 8.2, is a small addition to UriSigner that closes exactly this gap. It adds a $version argument to the signer so you can bind a signed URL to a piece of state that changes the moment the link is used. When that state changes, the old signature stops verifying. No database of consumed tokens, no cache entry to expire, no extra table. The invalidation is baked into the signature itself.
The core idea
UriSigner lives in the HttpFoundation component and has been the standard way to sign and verify URLs in Symfony for years. Its sign() method appends a hash derived from a secret, and check() or verify() confirms that hash on the way back in. Symfony 5.1 added expiration support, and 8.x already deprecates signing without an expiry, so signed URLs are expected to be time-limited by default.
The new feature adds one more input to the mix. Alongside the URI and the expiration, you can now pass a $version string. That value is folded into the signature but is not exposed in the URL itself, so it is not something an attacker can read or forge. The changelog entry describes it plainly: a new $version argument on UriSigner::sign(), UriSigner::check(), UriSigner::checkRequest(), and UriSigner::verify() that binds a signed URI to a state token.
The trick is choosing a version value that naturally changes after the link is used. For a password reset link, the obvious choice is the user’s current password hash.
Password reset, done properly
Here is what a password reset link looks like with the new argument.
use Symfony\Component\HttpFoundation\UriSigner;
$signer = new UriSigner($secret);
// Sign the reset link, binding it to the user's CURRENT password hash.
$link = $signer->sign(
'https://app.phparch.com/reset-password?token=abc123',
new \DateTimeImmutable('+1 hour'),
$user->getPassword(),
);
The user’s password hash goes in as the version. Now walk through the two moments that matter in your reset controller.
// BEFORE the user resets their password.
// getPassword() still returns the original hash, so the version matches.
$signer->verify($request, $user->getPassword()); // passes
// The user submits the form and you persist a new password hash...
// AFTER the reset.
// getPassword() now returns a DIFFERENT hash, so the version no longer matches.
$signer->verify($request, $user->getPassword()); // throws UnverifiedSignedUriException
Nothing about the link changed. What changed is the world the link was signed against. The moment the password hash rotates, every link signed with the old hash is dead. That is the whole feature, and its elegance is that the thing you already update as part of the reset (the password) is also the thing that revokes the link.
If you prefer a boolean over an exception, check() and checkRequest() take the same version argument and return true or false instead of throwing:
if (! $signer->check($signedUri, $user->getPassword())) {
throw $this->createAccessDeniedException('This reset link has already been used.');
}
Beyond password resets
The pattern generalizes to any link that should only work while some state holds steady. The pull request that introduced this called out two more cases directly.
A magic login link can be versioned on the user’s last login timestamp. Sign the link against the current timestamp, and the first successful login updates the timestamp, which invalidates the link for any second attempt. An email verification link can be versioned on a last-verified timestamp in the same way, so a verify link cannot be replayed after the address is already confirmed.
You can push the idea further. Bind an invitation link to the invited member’s status so that accepting the invite kills the link. Bind a “confirm this destructive action” link to a record’s updated_at so that the confirmation only works against the exact version of the row the user was looking at. Any time you can point at a value that changes as a side effect of the action, you get single-use behavior for free.
Terminology and a caveat
Worth noting that the API naming was still under discussion when the feature landed. The author flagged that he was not fully sold on the terms “version” and “stale,” and invited suggestions, so do not be surprised if the exact wording shifts before the stable release. The behavior is settled; the labels may not be.
The larger caveat is timing. This is on the Symfony development branch now, targeting the 8.2 release in November 2026. It is not in a stable release yet, so you cannot composer require your way to it on a production app today. If you need single-use signed URLs before then, the community has covered this ground already. The zenstruck/signed-url-bundle package offers temporary and single-use signed URLs with a friendly API, and coopTilleuls/UrlSignerBundle handles signed URLs with a limited lifetime. Both are worth a look if you are on current stable and cannot wait.
Why this belongs in the framework
You could always have built this yourself by storing a nonce and checking it off, but that means a table or a cache key, a cleanup job, and a race condition to reason about. Folding the state into the signature removes all of that. There is no server-side record to consume, which means nothing to leak, nothing to grow unbounded, and nothing to get out of sync across multiple app servers. For a component whose entire job is to let you trust a URL without trusting the person who is holding it, single-use support has been a conspicuous gap. It is good to see it filled with something this simple.
If you maintain any flow that emails a one-time link, put a reminder on your calendar for the Symfony 8.2 release. Swapping a homegrown nonce table for a version argument is the kind of change that removes code, removes a class of bugs, and makes the intent of the feature obvious to the next person who reads it.
Sources
- A Week of Symfony #1017 (June 22-28, 2026) — Symfony Blog
- feature #64408 [HttpFoundation] Allow creating single-use signed urls — symfony/symfony commit d0e3914
- Symfony 8.2 Release schedule — symfony.com
- New in Symfony 5.1: Improved UriSigner — Symfony Blog
- zenstruck/signed-url-bundle — Packagist
- coopTilleuls/UrlSignerBundle — GitHub