Blog

Sudo mode in Symfony 8.2: ask for the password again, but only when it matters

You know that moment on GitHub when you want to delete a repository and it stops you: "Confirm access". Type your password, carry on. That's sudo mode, and Symfony 8.2 ships it natively: two new security attributes, a timestamp on the token and an entry point that sends people to a confirm-password page instead of a 403. Let's build it.

Updated 8 min read

By David Gorges

Managing Director

Fully authenticated... since when?

Here's the problem with IS_AUTHENTICATED_FULLY: it tells you how the session started, not when. Someone logs in with their password at 9 in the morning, leaves the laptop unlocked in a café at 5, and whoever sits down next is still "fully authenticated". They can change the email address, add a payment method or delete the account. Symfony is perfectly happy with that.

GitHub solved this years ago with "sudo mode": sensitive actions ask for your password again unless you typed it a few minutes ago. Javier Eguiluz asked for the same thing in Symfony in issue #33955. That was October 2019. Seven years later Nicolas Grekas landed it as a stack of four pull requests, and it ships with Symfony 8.2 in November.

Yep, seven years. Good things take time. Let's see what we got.

Two attributes, two clocks

The feature boils down to two new attributes next to the ones you already know:

  • IS_AUTHENTICATED_RECENTLY: the user entered their credentials within the last two hours.
  • IS_AUTHENTICATED_VERY_RECENTLY: within the last five minutes.

Both are strictly stronger than IS_AUTHENTICATED_FULLY, and you use them exactly like it:

src/Controller/AccountController.php
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class AccountController extends AbstractController
{
    #[Route('/account/email', name: 'app_account_email')]
    #[IsGranted('IS_AUTHENTICATED_RECENTLY')]
    public function changeEmail(): Response
    {
        // ...
    }

    #[Route('/account/delete', name: 'app_account_delete')]
    #[IsGranted('IS_AUTHENTICATED_VERY_RECENTLY')]
    public function deleteAccount(): Response
    {
        // ...
    }
}
Changing the email needs a login from the last two hours, deleting the account one from the last five minutes.

The two windows are GitHub's numbers, and both are configurable, in seconds:

config/packages/security.yaml
security:
    recent_authentication_lifetime: 3_600       # default: 7_200 (2 hours)
    very_recent_authentication_lifetime: 60     # default: 300 (5 minutes)

The attributes also work in access_control, and security expressions get two matching functions, is_recently_authenticated() and is_very_recently_authenticated(). Handy in Twig: hide the dangerous button until clicking it would actually work.

Where does the timestamp come from?

This is my favourite part, because it's tiny. A new listener, AuthenticationTimeListener, subscribes to the good old InteractiveLoginEvent and writes the current time onto the token as an attribute called auth_time:

symfony/security-http: EventListener/AuthenticationTimeListener.php
final class AuthenticationTimeListener implements EventSubscriberInterface
{
    public function onInteractiveLogin(InteractiveLoginEvent $event): void
    {
        $event->getAuthenticationToken()->setAttribute(
            AuthenticatedVoter::AUTH_TIME_ATTRIBUTE, // 'auth_time'
            $this->clock?->now()->getTimestamp() ?? time()
        );
    }

    public static function getSubscribedEvents(): array
    {
        return [SecurityEvents::INTERACTIVE_LOGIN => ['onInteractiveLogin', 256]];
    }
}
Priority 256, so a listener that stops propagation can't leave the token without its stamp. The name auth_time is borrowed from OpenID Connect on purpose.

Token attributes are serialized into the session, so the stamp survives every request for free. The AuthenticatedVoter then compares auth_time against the configured lifetime. Three details are worth knowing:

  • Remember-me never passes. A remembered token gets the stamp like any other, but the voter checks isFullFledged() first. A cookie that anyone with the browser can replay is not proof of anything.
  • No stamp means no. Tokens without auth_time, say from a custom non-interactive authenticator, are denied. Safe default.
  • Impersonation has no stamp either. If you switch_user into someone, you won't pass the recency checks as them. Fair enough.

Oh, and the voter now says which attribute it denied. That shows up in the profiler, and the next part relies on it.

A 403 is not a user interface

So the user clicks "Delete account" three hours after logging in and gets... a 403. Thanks, Symfony. Correct, but useless: nobody tells them that typing their password would fix it.

You might think: just reuse the firewall's entry point and redirect to the login page. The team tried exactly that and it doesn't work. form_login redirects to login_path, the login page sees an authenticated user and bounces them straight back. HTTP basic replays the cached credentials without asking. You'd be building a redirect loop.

So 8.2 adds a dedicated interface, ReAuthenticationEntryPointInterface. Its docblock puts it nicely: the normal entry point answers "who are you?", and an existing session already satisfies that. Here the user is known, and the only thing we want is fresh proof. That's why it receives the token instead of an exception:

src/Security/ConfirmPasswordEntryPoint.php
namespace App\Security;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\EntryPoint\ReAuthenticationEntryPointInterface;

final class ConfirmPasswordEntryPoint implements ReAuthenticationEntryPointInterface
{
    public function __construct(private UrlGeneratorInterface $urlGenerator)
    {
    }

    public function startReAuthentication(Request $request, TokenInterface $token): Response
    {
        return new RedirectResponse($this->urlGenerator->generate('app_confirm_password'));
    }
}

Wire it into the firewall:

config/packages/security.yaml
security:
    firewalls:
        main:
            form_login: {}
            re_authentication_entry_point: App\Security\ConfirmPasswordEntryPoint
If your main entry point already implements the interface (the new OIDC authenticator does), Symfony picks it up without this line.

From now on, when access is denied and the only thing that failed is the recency attribute, Symfony's exception listener saves the requested URL as the target path and calls your entry point instead of rendering the 403. A missing role still gets the 403, which is exactly right: no amount of password typing will give you ROLE_ADMIN.

One compile-time safety net: put the option on a stateless firewall and the container refuses to build. Without a session there is nothing to stamp, so re-authentication would loop forever.

The confirm-password page

Symfony gets the user to the door; the page behind it is yours. It only needs to do two things: check the password and make Symfony re-stamp the token. The second part is where it gets elegant. Security::login() runs the regular form-login authenticator, which is interactive, which fires InteractiveLoginEvent, which refreshes auth_time. And because form_login honours the saved target path, the success response already redirects back to where the user wanted to go.

src/Controller/SecurityController.php
#[Route('/confirm-password', name: 'app_confirm_password')]
public function confirmPassword(
    Request $request,
    Security $security,
    UserPasswordHasherInterface $hasher,
): Response {
    $form = $this->createForm(ConfirmPasswordType::class);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $user = $this->getUser();

        if ($hasher->isPasswordValid($user, $form->get('password')->getData())) {
            // runs the form_login authenticator: InteractiveLoginEvent fires,
            // auth_time is refreshed and the saved target path is honoured
            return $security->login($user, 'form_login')
                ?? $this->redirectToRoute('app_account');
        }

        $form->addError(new FormError('That password is not correct.'));
    }

    return $this->render('security/confirm_password.html.twig', [
        'form' => $form,
    ]);
}
One password field, no email field: we already know who this is.

Two things to know before you ship it

Both limitations are documented in the pull request, and both make sense once you think about them:

  • Only safe requests save the return URL. If the first protected request is the POST of the delete button itself, no target path is stored and the user lands on your default page after confirming. Guard the GET that shows the confirmation form, not just the POST that performs the action.
  • Expressions don't trigger the entry point. allow_if: "is_recently_authenticated()" in access_control gives you a plain 403. The redirect branch matches the attribute, not the expression. Use the attribute wherever you want the nice flow.

Beyond the clock: your own trust resolver

The last PR in the stack moved the decision out of the voter into AuthenticationTrustResolver, which gained isAuthenticatedRecently() and isAuthenticatedVeryRecently(). Time is just the default strategy. If "recent" means something else in your domain, extend the resolver. The release post shows a bank that only trusts a hardware key:

src/Security/BankTrustResolver.php
final class BankTrustResolver extends AuthenticationTrustResolver
{
    public function isAuthenticatedVeryRecently(?TokenInterface $token = null): bool
    {
        if (!$this->isFullFledged($token)) {
            return false;
        }

        $proofs = $token->getAuthenticationProofs();

        return isset($proofs[AuthenticationMethod::HARDWARE_KEY])
            && time() - max($proofs) <= 300;
    }
}
The isFullFledged() guard stays in the voter no matter what you return here: a custom resolver can't upgrade remember-me into sudo.

The proofs come from an AuthenticationMethodBadge that authenticators attach, with method names following RFC 8176, the same vocabulary OpenID Connect uses for its amr claim. Speaking of which: the new OIDC login authenticator reads the ID token's auth_time claim and stamps that, so a silent re-authentication at the provider doesn't count as a fresh login. It also implements the re-authentication entry point itself and sends the user back to the provider with prompt=login. Zero configuration for the OIDC crowd.

What's next

Symfony 8.2 is due in November. If you want to play now, composer require symfony/security-bundle:8.2.x-dev on a throwaway app does the trick, and the four PRs (#66014, #66015, #66016, #66035) are very readable if you want the reasoning behind every detail.

Our suggestion: open your controllers and look for the three or four actions that would hurt if the wrong person clicked them. Email change, password change, payout details, account deletion, API key generation. Put IS_AUTHENTICATED_RECENTLY on them, build one confirm-password page, done. That's an afternoon, and it closes a hole most apps have had since their first login form.

And if you've been running a home-grown sudo mode for years (the issue thread is full of people who did), check how much of it you get to delete now. Deleting code is the best part of an upgrade.

Contents