Signed URLs let you share links that can't be tampered with, such as password reset links, email confirmation links or download links. Symfony provides the UriSigner utility to create and check them, and login links to log in users without a password. Symfony 8.2 makes both more secure and flexible.
Single-Use Signed URLs
A signed URL stays valid until it expires, no matter how many times it's used. This is a problem for links like password resets, which should stop working once they do their job. Until now, you had to implement your own invalidation logic for these links (e.g. storing some token in the database).
In Symfony 8.2, the sign(), check(), checkRequest() and verify()
methods of UriSigner accept a new $version argument. Its value is added
to the signature (but not to the URL), so the URL is valid only while that value
stays the same. Use a value that changes once the link is used, like the hash
of the user password:
1 2 3 4 5 6 7 8 9 10 11 12
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
// when sending the password reset email
$url = $uriSigner->sign(
$this->generateUrl('reset_password', ['id' => $user->getId()], UrlGeneratorInterface::ABSOLUTE_URL),
new \DateInterval('PT30M'),
$user->getPassword(),
);
// in the controller that handles the link
$uriSigner->verify($request, $user->getPassword());
Calling verify() doesn't invalidate the link. The link becomes invalid
once the application saves the user's new password hash. Any subsequent
call to verify() with that hash throws an UnverifiedSignedUriException.
The same idea works for other links: use the last login date for login links or the email verification date for email confirmation links.
Requiring an Expiration for Signed URLs
Signed URLs without an expiration are valid forever, so a link that leaks in
an old email or a log file can be used at any time. That's why Symfony 8.2
deprecates calling UriSigner::sign() without an expiration. In Symfony 9.0
the expiration will be required.
You can pass the expiration in each sign() call or define a default one
via the new $defaultExpiration constructor argument (a \DateInterval
or a number of seconds). If you use the uri_signer service of the framework,
set the default expiration (in seconds) with the new uri_signer.expiration
option:
1 2 3 4
# config/packages/framework.yaml
framework:
uri_signer:
expiration: 3600
Signed Parameters in Login Links
Login links contain the user identifier, the expiration date and a signature. If you added other query parameters to these links (e.g. the page to redirect to after login), anyone could change them because they weren't signed.
In Symfony 8.2, the createLoginLink() method accepts a new $parameters
argument. These parameters are added to the link URL and covered by its signature:
1 2 3
$loginLinkDetails = $loginLinkHandler->createLoginLink($user, parameters: [
'return_to' => '/account/settings',
]);
If someone changes or removes any of these parameters, the login link becomes
invalid. When the link is used, Symfony stores the verified parameters in the
_login_link_parameters request attribute:
1
$returnTo = $request->attributes->get('_login_link_parameters')['return_to'] ?? null;
Caution
In your authentication success handler, always read these values from the
_login_link_parameters attribute and not from the query string. Symfony
ignores the extra query parameters that are not signed (e.g. tracking
parameters added by email clients), so anyone can add them to the URL.