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:
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
{
// ...
}
}
The two windows are GitHub's numbers, and both are configurable, in seconds:
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:
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]];
}
}
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_userinto 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:
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:
security:
firewalls:
main:
form_login: {}
re_authentication_entry_point: App\Security\ConfirmPasswordEntryPoint
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.
#[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,
]);
}
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
POSTof the delete button itself, no target path is stored and the user lands on your default page after confirming. Guard theGETthat shows the confirmation form, not just thePOSTthat performs the action. - Expressions don't trigger the entry point.
allow_if: "is_recently_authenticated()"inaccess_controlgives 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:
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 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.