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

Kevin Bond
Contributed by Kevin Bond in #64408

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

Kevin Bond
Contributed by Kevin Bond in #64455

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
Jan Klan
Contributed by Jan Klan in #64271

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.

Published in #Living on the edge