Being logged in is not enough: asking for proof again before sensitive actions
This article starts from an exercise you can do in your browser: Asking for the password again before sensitive pages →
In most applications, authentication is a boolean. You are logged in or you are not, and once you are, everything is open: viewing your invoices, changing your email address, deleting your account, generating an API key.
Yet "logged in" covers very different situations. A session opened a minute ago with a password and a code. A session restored this morning by a "remember me" cookie set three weeks ago. A computer left unlocked over lunch. A session cookie stolen by a browser extension. To read a dashboard, the difference hardly matters. To change the address used to reset the password, it matters a lot.
GitHub asks for your password again before you delete your account or create an access token. Your bank asks for a code before a transfer to a new payee. This is called step-up authentication: you don't ask for everything all the time, but for an extra proof at the moment it counts.
Symfony 8.2 finally brings the building blocks to do it properly. But before the code come design choices, and they are what makes the difference.
Three questions instead of one
An authenticated session answers one question: who the user is. To decide whether they may perform a sensitive action, you need two more.
- When did they last prove their identity? Not when the session started: when they last did something only they could do.
- How did they prove it? A password, a code received by SMS, a security key, a magic link in their inbox.
These questions are not new. OpenID Connect has long answered them in the ID token: auth_time says when the user authenticated, amr (authentication methods references) lists the methods used, and the client can require a recent authentication with the max_age parameter. RFC 8176 standardizes the amr values: pwd for a password, sms, otp, hwk for a hardware key, and so on. If your application delegates login to an identity provider, a good part of this article is settled in that provider's configuration, and your application only has to read this information.
If your application does the authenticating, it is up to it to remember these answers.
Which actions to protect
Protecting everything amounts to protecting nothing: a user asked for their password every ten minutes ends up pasting it from a text file, or leaving the service. I sort actions with three criteria.
Does the action allow taking over the account? Changing the email address, the password, the phone number, disabling two-factor authentication, adding a security key. These are the top targets, because an attacker who succeeds once no longer needs the stolen session: they have the account. Changing the email is the most underestimated: it doesn't look like a security action, but it is the path password resets go through.
Is the action irreversible, or does it cost money? A transfer, an order, deleting an account or a project.
Does the action export data in bulk? A full export, creating an API key or an access token, adding a member with admin rights.
Everything else (browsing, editing a public profile, changing preferences) can usually make do with the session.
Two freshness levels are often enough. A proof less than a few hours old for sensitive pages to view, like invoices or login history. A proof a few minutes old for actions that hand over control of the account. The exact durations depend on your domain: a banking app does not have the same constraints as a project management tool.
Asking for the right proof
Asking for proof again, yes, but which one? Two mistakes come up often.
Asking for a weaker proof than the one used to log in. If the user logged in with a password and a security key, asking only for a code sent by email before disabling two-factor authentication opens a shortcut for anyone with access to their inbox. The proof you ask for must be at least as strong as the one the action lets you change or disable.
Believing all second factors are equal. SMS is still widespread, but it is exposed to SIM swapping: NIST classifies it as a "restricted" authenticator, to be used only knowingly and with an alternative offered to the user. GitHub, for instance, does not accept it for its sudo mode. An authenticator app (TOTP) or a security key (WebAuthn) is stronger. SMS is better than nothing, but that is no reason to make it the only safeguard for a large transfer.
And sometimes there is nothing to ask for: a user who never registered a phone cannot provide a code. Sending them to a form they cannot fill in leads nowhere. For them, a clear refusal, with instructions to add a second factor, is more honest than a dead end.
Don't break the flow
A successful re-authentication must take the user back exactly where they were going. Otherwise they start over, get tired, and rightly blame you for wasting their time.
A classic trap: protecting only the POST request that saves the form. The user fills in the address change form, submits it, gets sent to the password page… and their input is lost. Symfony's firewall, like most mechanisms of this kind, only remembers the target page for a read request (GET), excluding Ajax. It cannot replay a POST. The fix is simple: also protect the display of the form. The proof is requested before the user types anything, and it is still valid when they submit.
For an API, redirecting to a form makes no sense. RFC 9470 (OAuth 2.0 Step-Up Authentication Challenge Protocol) describes the expected response: a 401 with a WWW-Authenticate header carrying the insufficient_user_authentication error, and optionally max_age or acr_values to say what is missing. The client then knows it must start a stronger authentication, rather than display "access denied".
The re-authentication screen is a login screen
A page that only asks an already logged-in user for their password looks harmless. Yet it is exactly what someone who stole a session is looking for: a form to try passwords on, without having to guess the username. It therefore needs the same protections as a login form: attempt limiting, logging of failures, and one-time codes that expire quickly.
After a successful sensitive action, notify the user through another channel. An email saying "your address was changed", sent to the old address with a link to undo it, catches the case where every other protection was bypassed. It is the cheapest safety net in this article.
Finally, a session restored by a "remember me" cookie must never count as a recent proof. The cookie proves that a browser was logged in at some point, not that the person in front of the screen knows the password.
Before Symfony 8.2: a timestamp in the session
Up to Symfony 8.1, nothing is built in for the "when" or the "how". IS_AUTHENTICATED_FULLY only distinguishes a session opened by a form from one opened by the "remember me" cookie. So you have to record the time of the last proof yourself, for example in a LoginSuccessEvent listener:
#[AsEventListener] public function __invoke(LoginSuccessEvent $event): void { // The "remember me" cookie triggers this event too: it proves nothing. if ($event->getAuthenticatedToken() instanceof RememberMeToken) { return; } $event->getRequest()->getSession()->set('last_proof', time()); }
Then a voter compares this timestamp with the current time. It works, but everything is on you: recording the method as well as the time, redirecting to the right page instead of a 403, not forgetting the cookie case. It is exactly the kind of code you write once, halfway, and never read again.
What Symfony 8.2 brings
Symfony 8.2 (still in development as I write this) builds this mechanism into the Security component, in four pieces.
Freshness. Two new attributes, IS_AUTHENTICATED_RECENTLY (under two hours by default, the window of GitHub's sudo mode) and IS_AUTHENTICATED_VERY_RECENTLY (under five minutes), work wherever IS_AUTHENTICATED_FULLY does: #[IsGranted], access_control, Twig. The durations are set in security.yaml. A session opened by a "remember me" cookie fails both.
Re-authentication instead of refusal. When one of these attributes is denied, the firewall no longer responds with a 403: it remembers the requested page and calls a re-authentication entry point. form_login provides one, which redirects to the login page with the username already filled in. For a page that only asks for the password, you write your own by implementing ReAuthenticationEntryPointInterface, and declare it on the firewall with re_authentication_entry_point.
The "how". The token now records every method proven during the session, with the time it was last used, using the names from RFC 8176:
$token->getAuthenticationProofs(); // ['pwd' => 1792300000, 'sms' => 1792300060]
An authenticator declares its method with an AuthenticationMethodBadge on the passport. Only interactive authentications count, and the "remember me" cookie is explicitly excluded. An authenticator without a badge is recorded under the * method, unspecified. On the OpenID Connect side, the OIDC authenticator reads amr and auth_time from the ID token to feed these proofs. A voter can then require, for example, an sms proof less than five minutes old before a transfer.
Your own rules. A voter can tell the firewall that a new proof would change its answer, with $vote->requestReAuthentication($attribute). The entry point reads which attribute was denied and chooses what to ask for: a code for the transfer, the password for everything else. And the voter can also signal nothing, for the user who has no second factor: they keep their 403. One limit to know: the mechanism only kicks in when a single attribute is denied. A check combining several always yields a 403.
The when, the how and what to ask for again each have their answer in the framework. The choices remain yours: which actions to protect, with which proof, and for how long.
Try it
I turned these features into three exercises, to do in your browser, free and without an account, on a development build of Symfony 8.2:
- Asking for the password again before sensitive pages, sudo mode;
- Require an SMS code before a transfer, authentication proofs;
- Ask for the right factor again instead of a 403, re-authentication from your voters.