Currently I am building software in Symfony 5.2 and noticed that my remember me was not working correctly (many of my users kept complaining about getting logged out everyday). I debugged it and found that concurrent requests can actually break the remember me system because for some reason the PersistentTokenBasedRememberMeServices class updates the remember me cookie on every request.

The reason this was happening for me was I recently moved to store remember me tokens in the database by following the official guide. Come to find out when you do this it will start updating remember me tokens on every single request (there is an open issue here about this but with very little attention). If you have any sort of ajax calls that happen in the background on your app (in my case I had timecard software I developed inside my app that would update every few seconds to display how long the user has been clocked in) and you switch pages in the middle of one of these requests it will break the remember me cookie and it will get deleted.

There is a couple ways to fix this. You could just not store the remember me tokens in the database and all will be well. I personally want them in the database so I chose to copy the Symfony\Component\Security\Http\RememberMe\PersistentTokenBasedRememberMeServices class and remove the part that updates on every request. I only care about these tokens being updated when the user logins (just like how the original TokenBasedRememberMeServices worked). Another solution would be to only update the cookie on non XHR requests but it would still be possible for the user to switch pages quickly and break their cookie. I think the best solution would be to make cookies last for 60-120 seconds after they get updated so that previous requests do not fail but you are still rotating your remember me tokens. I decided to only update the token when the user logins but if you would like to do the later you can look at this PR I found for inspiration.

The Fix

To disable updating the remember me cookie on every request create the following class in your project:

<?php

namespace App\Security\RememberMe;

use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentToken;
use Symfony\Component\Security\Core\Authentication\RememberMe\PersistentTokenInterface;
use Symfony\Component\Security\Core\Authentication\RememberMe\TokenProviderInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CookieTheftException;

class PersistentTokenBasedRememberMeServices extends \Symfony\Component\Security\Http\RememberMe\PersistentTokenBasedRememberMeServices
{
    private const HASHED_TOKEN_PREFIX = 'sha256_';

    /** @var TokenProviderInterface */
    private $tokenProvider;

    public function setTokenProvider(TokenProviderInterface $tokenProvider)
    {
        $this->tokenProvider = $tokenProvider;
    }

    /**
     * {@inheritdoc}
     */
    protected function cancelCookie(Request $request)
    {
        // Delete cookie on the client
        parent::cancelCookie($request);

        // Delete cookie from the tokenProvider
        if (null !== ($cookie = $request->cookies->get($this->options['name']))
            && 2 === \count($parts = $this->decodeCookie($cookie))
        ) {
            [$series] = $parts;
            $this->tokenProvider->deleteTokenBySeries($series);
        }
    }

    /**
     * {@inheritdoc}
     */
    protected function processAutoLoginCookie(array $cookieParts, Request $request)
    {
        if (2 !== \count($cookieParts)) {
            throw new AuthenticationException('The cookie is invalid.');
        }

        [$series, $tokenValue] = $cookieParts;
        $persistentToken = $this->tokenProvider->loadTokenBySeries($series);

        if (!$this->isTokenValueValid($persistentToken, $tokenValue)) {
            throw new CookieTheftException('This token was already used. The account is possibly compromised.');
        }

        if ($persistentToken->getLastUsed()->getTimestamp() + $this->options['lifetime'] < time()) {
            throw new AuthenticationException('The cookie has expired.');
        }

        // commented out otherwise the remember me cookie is updated every request
        // causing concurrent requests to break the remember me
//        $tokenValue = base64_encode(random_bytes(64));
//        $this->tokenProvider->updateToken($series, $this->generateHash($tokenValue), new \DateTime());
//        $request->attributes->set(self::COOKIE_ATTR_NAME,
//            new Cookie(
//                $this->options['name'],
//                $this->encodeCookie([$series, $tokenValue]),
//                time() + $this->options['lifetime'],
//                $this->options['path'],
//                $this->options['domain'],
//                $this->options['secure'] ?? $request->isSecure(),
//                $this->options['httponly'],
//                false,
//                $this->options['samesite']
//            )
//        );

        return $this->getUserProvider($persistentToken->getClass())->loadUserByUsername($persistentToken->getUsername());
    }

    /**
     * {@inheritdoc}
     */
    protected function onLoginSuccess(Request $request, Response $response, TokenInterface $token)
    {
        $series = base64_encode(random_bytes(64));
        $tokenValue = base64_encode(random_bytes(64));

        $this->tokenProvider->createNewToken(
            new PersistentToken(
                \get_class($user = $token->getUser()),
                $user->getUsername(),
                $series,
                $this->generateHash($tokenValue),
                new \DateTime()
            )
        );

        $response->headers->setCookie(
            new Cookie(
                $this->options['name'],
                $this->encodeCookie([$series, $tokenValue]),
                time() + $this->options['lifetime'],
                $this->options['path'],
                $this->options['domain'],
                $this->options['secure'] ?? $request->isSecure(),
                $this->options['httponly'],
                false,
                $this->options['samesite']
            )
        );
    }

    private function generateHash(string $tokenValue): string
    {
        return self::HASHED_TOKEN_PREFIX.hash_hmac('sha256', $tokenValue, $this->getSecret());
    }

    private function isTokenValueValid(PersistentTokenInterface $persistentToken, string $tokenValue): bool
    {
        if (str_starts_with($persistentToken->getTokenValue(), self::HASHED_TOKEN_PREFIX)) {
            return hash_equals($persistentToken->getTokenValue(), $this->generateHash($tokenValue));
        }

        return hash_equals($persistentToken->getTokenValue(), $tokenValue);
    }
}

Now define it as a service to override the default PersistentTokenBasedRememberMeServices:

services:
    security.authentication.rememberme.services.persistent:
        class: App\Security\RememberMe\PersistentTokenBasedRememberMeServices
        parent: security.authentication.rememberme.services.abstract
        calls:
            - setTokenProvider: ['@Symfony\Bridge\Doctrine\Security\RememberMe\DoctrineTokenProvider']

And now your remember me tokens will only update when the user logins!

Conclusion

I really hope this becomes an option that you can turn on/off within Symfony without having to override the class. I thought it was rather silly that this behavior isn't really documented anywhere and just crops up when you start storing remember me tokens in the DB. I get that they want to refresh the token as often as possible to prevent hijacking but breaking concurrent requests to fulfill this just isn't worth it.

I hope others found this useful. I didn't find anyone else that had ran into this issue and provided a fix. If this ended up helping you out feel free to show your gratitude by leaving a comment. I enjoy hearing from my readers.