All posts

Sudo Mode in Symfony 8.2: The Rule That Silently Drops the Freshness Check

October 5, 2026Michał Woliński15 min read
Sudo Mode in Symfony 8.2: The Rule That Silently Drops the Freshness Check

A user logs in with their password in the morning, leaves the laptop open over lunch, and comes back to a session that is still valid. Nothing about that session is broken. The password was correct, the session cookie is HttpOnly and Secure, the CSRF token is in place, and IS_AUTHENTICATED_FULLY is true. Anyone walking past the desk can now change the account email address, rotate API keys, or delete the account, and every one of those requests will look perfectly legitimate to the server.

This is the gap that sudo mode closes, and Symfony 8.2 adds it natively. Before looking at how, it is worth being precise about why the defences most projects already have do not cover it, because the confusion between them is where the false sense of security lives.

CSRF protection answers a question about origin: did this request come from a form we served, or from some other site the browser was tricked into visiting? It does nothing about a person using your form from your page on your machine. IS_AUTHENTICATED_FULLY answers a question about how the session started: it distinguishes a real login from a remember-me cookie. It says nothing about when that login happened. An eight-hour-old password login and a nine-second-old one are indistinguishable to it, and that is exactly the distinction a payment confirmation, a password change, or an account deletion needs.

GitHub and other services have shipped this as "sudo mode" for years. Symfony 8.2 is the first version where it is a security primitive rather than a session flag you maintain yourself. This article walks through the whole mechanism and then runs it: every behavioural claim below is produced by a 42-check suite that boots a real kernel and moves a clock, in a repository you can clone.

The two attributes

The feature is two security attributes that sit above IS_AUTHENTICATED_FULLY in strictness. They were contributed by Nicolas Grekas and announced in the Symfony 8.2 release notes:

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

You use them anywhere you already use the other IS_AUTHENTICATED_* attributes. On a controller:

// src/Controller/AccountController.php use Symfony\Component\Security\Http\Attribute\IsGranted; #[IsGranted('IS_AUTHENTICATED_RECENTLY')] public function changeEmail(): Response #[IsGranted('IS_AUTHENTICATED_VERY_RECENTLY')] public function deleteAccount(): Response

In access_control, in Twig, and in security expressions, which gain two functions — is_recently_authenticated() and is_very_recently_authenticated():

{% if is_recently_authenticated() %} <a href="{{ path('account_email') }}">Change email</a> {% endif %}

Both windows are configuration, and they are top-level options under security: — not firewall options, which is worth stating because it is the first thing people get wrong. The defaults are 7200 and 300 seconds, and both are declared as integers with a minimum of 0. The demo writes them out even though they are the defaults, so that the windows it tests are visible in the file rather than implied by a framework default:

security: # Both are top-level security options, not firewall options. Defaults are # 2 * 3600 and 5 * 60, which is what the demo would use if these were absent. recent_authentication_lifetime: 7200 very_recent_authentication_lifetime: 300

The minimum of 0 is not a footnote. Setting very_recent_authentication_lifetime: 0 means the very-recent check can only ever be satisfied in the same second as the login, which is a legitimate way to say "always ask again before this action" without disabling the attribute.

What actually decides it

The attributes are decided by the trust resolver, and the implementation is short enough to read in full. This is the real method from symfony/security-core as run in the demo:

private function isAuthenticatedWithin(?TokenInterface $token, int $lifetime): bool { if (null === $token || !$this->isFullFledged($token)) { return false; } if (!method_exists($token, 'getAuthenticationProofs')) { trigger_deprecation('symfony/security-core', '8.2', /* ... */); return false; } if (!$proofs = $token->getAuthenticationProofs()) { return false; } return ($this->clock?->now()->getTimestamp() ?? time()) - max($proofs) <= $lifetime; }

Four things in those eleven lines decide the behaviour most people will get wrong.

The token now carries authentication proofs. getAuthenticationProofs() returns a map of authentication method to the Unix timestamp of the last time that method was proved, for example ['pwd' => 1789140200, 'hwk' => 1789133290]. That is a new concept in 8.2, and it is what makes the check richer than a single "last login" timestamp. It is also why a custom token or a custom trust resolver that does not implement the new methods triggers a deprecation rather than failing silently: both are scheduled to be added to their interfaces in 9.0.

The decision uses max(), not "the login". The most recent proof of any method wins. A user who typed their password three hours ago but touched a hardware key a minute ago passes both checks, because max() over the proofs is one minute old. The demo asserts this explicitly, because it inverts the mental model of "when did they log in".

The comparison is <=. A proof exactly 7200 seconds old still counts as recent. The demo checks both sides of the boundary — 7200 passes, 7201 fails — so the off-by-one is pinned down rather than assumed.

Nothing is recorded for non-interactive authentication. The listener that writes the proofs only records them for authenticators that declare themselves interactive and whose token is not a remember-me token. HTTP Basic and access-token authentication are not interactive, so their users never hold these attributes at all — a fact that matters for the API discussion later.

The step-up usually needs no code of yours

A denied IS_AUTHENTICATED_RECENTLY is a 403 by default, which is a poor answer for a user who simply logged in too long ago. What they should get is a prompt for their credentials and then, ideally, a return to what they were doing. Symfony 8.2 makes that a first-class response through a new interface:

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')); } }

and one firewall option, which is a firewall option and sits next to entry_point:

security: firewalls: main: form_login: login_path: app_login check_path: app_login re_authentication_entry_point: App\Security\ConfirmPasswordEntryPoint

The name is deliberately not AuthenticationEntryPointInterface, and the interface's own source explains why better than a paraphrase would: it is "deliberately not an AuthenticationEntryPointInterface: that one answers 'who are you?' and an existing session already satisfies it, so reusing it would send the user to a login page that lets them straight back through." The user is known. The only thing being asked for is fresh proof that they still hold their credentials, which is why the method receives the token rather than an exception.

Here is the part that saves the most code, and it is not obvious from the release announcement: if your firewall uses form_login, you do not need to write that entry point at all. FormLoginAuthenticator itself implements ReAuthenticationEntryPointInterface, and the firewall falls back to its own entry point when that entry point implements the interface. With form_login configured, sudo mode redirects to your login form, prefilled with the user's identifier, and the user is back on their feet with no custom class. The docs say the same of oidc_login, which sends the user back to the provider with prompt=login and the id_token_hint, and uses the ID token's auth_time claim so that a silently resumed provider session does not count as a fresh login.

You write an entry point when you want a dedicated confirmation page rather than your login form — a different copy, a different layout, a different flow. That is a product decision, not a requirement.

The flow, as it actually runs

The demo boots a real kernel, logs in through form_login, and moves a file-backed clock forward instead of waiting. This is the actual output, trimmed of the framework's own log lines:

POST /login -> 302 http://localhost/ POST /account/delete -> 200 GET /account/email -> 200 -- clock advanced by 10 minutes -- GET /account/email -> 200 POST /account/delete -> 302 /confirm-password POST /login -> 302 http://localhost/ POST /account/delete -> 200 -- clock advanced by 3 hours -- GET /account/api-keys -> 302 /confirm-password POST /login -> 302 http://localhost/account/api-keys GET /account/api-keys -> 200

Read that sequence as the timeline of one session. Ten minutes after logging in, the recent action still runs and the very-recent one is redirected to the confirmation page. Three hours later, the recent action is redirected too. After re-authentication, the action succeeds.

Two details in that transcript are worth more than the happy path.

The framework records which attribute caused the denial, as a request attribute named _security.re_authentication_attribute. The demo asserts its value at each step, and it is the reason an entry point can offer the same question in several ways — a password form here, a hardware key there — and still know what was denied. It is set by the same listener that saves the target path.

And the two redirects after re-authentication go to different places, for a reason that deserves its own section.

Where the user lands afterwards

The promise of a step-up is usually stated as "and then they are returned to what they were doing". Symfony attempts exactly that, and it is bounded by a one-line condition in the firewall's exception listener:

protected function setTargetPath(Request $request): void { // session isn't required when using HTTP basic authentication mechanism for example if ($request->hasSession() && $request->isMethodSafe() && !$request->isXmlHttpRequest()) { $this->saveTargetPath($request->getSession(), $this->firewallName, $request->getUri()); } }

isMethodSafe() is the whole story. Safe methods — GET, HEAD, OPTIONS, TRACE — have their target path saved, so a denied GET /account/api-keys is replayed after re-authentication, which is the third block in the transcript above. A denied POST is not replayed: the user lands on the default target path, and the action has to be submitted again. That is the first and fourth blocks, where POST /login redirects to /.

This is not a defect, but it lands squarely on the actions sudo mode exists for. Deleting an account, rotating a key, confirming a payment — these are POSTs, and for them the "return to the original URL" behaviour does not apply. If your confirmation page promises to finish the job, it will not. The honest design is to send the user back with the action unperformed and let them submit it a second time, which is also the safer default: a replay of a state-changing request is precisely the thing you did not want to replay.

There is a second, sharper way to lose that return trip, documented in FormLoginAuthenticator's own source: the user lands back on the denied resource "as long as the success handler uses the target path saved before this, which always_use_default_target_path and a _target_path of the form both outrank." If you have set always_use_default_target_path: true, or your login form posts a hidden _target_path field, the saved path loses and everyone lands on the default page. If the step-up appears not to work while the redirect clearly happens, look there first.

When no step-up is offered

The step-up is not offered for every denial, and the rule is stricter than most descriptions of it suggest. It lives in a private method with a precise name — getReAuthenticationAttribute() — whose first check is the whole rule:

$attributes = $exception->getAttributes(); if (1 !== \count($attributes)) { return null; }

If the denied check names more than one attribute, null comes back, the entry point is never consulted, and the user gets a plain 403. The reasoning is stated in the source as well: "Several attributes are left alone, as deciding them at once is deprecated since Symfony 8.2. The votes of the checks a voter makes on other attributes land in the same decision, so the request has to name the denied attribute for the denial to be told apart from them." An expression or a closure is the exception, because its checks are decided apart — there the voter can name the one check a re-authentication would grant.

That first clause is the one to read twice. This is not a permanent asymmetry in the framework; it is the visible edge of a deprecation. Deciding several attributes at once is on its way out, and the demo's own run prints the notice whenever the deprecated shape is evaluated:

[info] User Deprecated: Since symfony/security-core 8.2: Passing more than one Security attribute to "...AccessDecisionManager::decide()" is deprecated, pass a single attribute instead. For an "access_control" rule, move the roles to "allow_if" or to the role hierarchy. The "$allowMultipleAttributes" argument will be removed in 9.0.

So the missing step-up is a symptom with a deadline and a prescribed fix, and Symfony names the fix itself: move the roles to allow_if, or to the role hierarchy.

That second suggestion is a general template, and in this combination it is a dead end — worth knowing before you follow it. A role hierarchy expands role names into other role names: RoleHierarchyVoter extends RoleVoter and overrides only the method that extracts the user's roles, while the voting itself skips every attribute that does not start with ROLE_. IS_AUTHENTICATED_RECENTLY is not a role — it is defined and decided by AuthenticatedVoter, a different voter — so it never reaches the hierarchy, and no mapping under role_hierarchy can express "and the login is fresh". The advice fits a rule that combines two real roles; for a role plus freshness, allow_if is the only one of the two suggestions that can carry the check. The demo asserts both halves in the same voter, so the abstention cannot be mistaken for a voter that simply never grants: the hierarchy grants ROLE_SUPER_ADMIN to an admin it reaches through the mapping, and abstains on the freshness attribute in the next call.

That makes "what do I do instead" a question with a verifiable answer, so the demo runs the three shapes side by side — the same stale administrator, the same two conditions, three constructs:

roles: [IS_AUTHENTICATED_RECENTLY, ROLE_ADMIN] -> 200 allow_if: "is_granted('IS_AUTHENTICATED_RECENTLY') and is_granted('ROLE_ADMIN')" -> 302 /confirm-password #[IsGranted('IS_AUTHENTICATED_RECENTLY')] + #[IsGranted('ROLE_ADMIN')] -> 302 /confirm-password

The first line is the trap, and it is worse than the 403 this section started from. A roles array in access_control is an OR, not an AND. The strategy is affirmative, so the rule grants access to anyone holding either attribute — and an administrator with a three-hour-old session holds ROLE_ADMIN, so the freshness requirement is not merely unenforced, it is passed over in silence. The demo asserts that 200 explicitly, because it is the outcome nobody expects: a rule written to require both conditions accepts the request, and nothing in the response suggests a check was skipped. It is also why the same rule returns 403 for a non-admin and 200 for an admin — the OR decides, and the two cases look unrelated until you know why. My own first version of this experiment assumed AND and asserted 403 for the administrator; the suite failed, and the failure was the finding.

The other two lines are the ways out, and both offer the step-up. An allow_if expression carrying the same two conditions as one check redirected the stale administrator to the confirmation page, with the framework logging Starting a re-authentication for "IS_AUTHENTICATED_RECENTLY" — the expression is decided as its own check, so the denied attribute can be named, which is exactly the case the docblock carves out. Two separate #[IsGranted] attributes behave the same way for the same reason, and neither option is at risk from the deprecation: each attribute is passed to its own decide() call, so the count($attributes) > 1 path that was deprecated is never reached. The choice between them is about where the check lives, not about what survives — allow_if keeps it in access_control alongside your routing rules, stacked attributes keep it on the method next to the action. When the rule is already in access_control and names several attributes, allow_if is the replacement the deprecation points at.

One practical note if you take that route: allow_if needs the ExpressionLanguage component, and a kernel without it fails at container compile time rather than at request time. The demo did not have it installed, and the error message names the fix — composer require symfony/expression-language.

Remember me never passes

A user whose session comes from a remember-me cookie never holds these attributes, however recently the cookie was used. The resolver rejects the token before it looks at the proofs, because a full-fledged check fails first:

if (null === $token || !$this->isFullFledged($token)) { return false; }

This is a decision rather than a side effect, and the demo asserts it directly: a remember-me token carrying a completely fresh proof still fails both checks. The reasoning in the release notes is that the cookie could be used by anyone with access to the browser — so the mere presence of a cookie cannot be a proof that the person at the keyboard is the account holder. Note also that RememberMeAuthenticator reports itself as interactive; the exclusion comes from the token type check in the proofs listener, not from the authenticator declining. If you are extending this mechanism, that distinction is where your assumption will be wrong.

Customizing the check

Time is the default criterion, not the only permitted one. The trust resolver is a service, and the two methods are meant to be overridden. A banking application might additionally require that a hardware key was used during the session:

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; } }
services: security.authentication.trust_resolver: class: App\Security\BankTrustResolver arguments: - '%security.recent_authentication_lifetime%' - '%security.very_recent_authentication_lifetime%' - '@clock'

The method names come from RFC 8176 and are exposed as constants on AuthenticationMethod — PASSWORD is 'pwd', HARDWARE_KEY is 'hwk', UNSPECIFIED is '*'. The built-in authenticators fill them in for you: form_login and json_login record PASSWORD, oidc_login takes the methods from the ID token's amr claim along with the time from auth_time, and login_link records UNSPECIFIED. A custom authenticator records nothing unless it adds an AuthenticationMethodBadge to its passport naming the methods it checked.

There is a consequence of the proof map that is easy to miss, and it is about re-authentication. When the same user authenticates again during a session, the new token does not start from an empty proof set — it is merged with the previous one:

$token->setAuthenticationProofs($token->getAuthenticationProofs() + $previousProofs);

The array union keeps the new timestamp for a method that was just proved and carries the other methods over with their original timestamps. So after a password-only confirmation, a user who logged in with a hardware key in the morning still has 'hwk' in the map, pointing at the morning. That is deliberate, and it is what makes "check the second factor" survive a later password re-entry. But it changes the meaning of the check you write: isset($proofs[AuthenticationMethod::HARDWARE_KEY]) && time() - max($proofs) <= 300 reads as "a hardware key was used at some point, and something was proved in the last five minutes" — not "the hardware key was used in the last five minutes". When a key's own freshness is the requirement, the timestamp to read is $proofs[AuthenticationMethod::HARDWARE_KEY], not max($proofs). The demo pins both readings down with explicit checks.

Stateless firewalls cannot do this

This is the limit of the feature, and Symfony enforces it loudly rather than letting you discover it in production. Setting re_authentication_entry_point on a stateless firewall fails container compilation with this message:

"The 're_authentication_entry_point' option cannot be used on the stateless firewall '%s': it has no session to record when the user authenticated, so IS_AUTHENTICATED_RECENTLY is always denied and re-authentication would loop."

The reason is structural. The proofs live on the token, and the token is written into the session at the end of each request and read back at the start of the next — the demo's own log shows the framework doing exactly that, line after line. A stateless firewall has nowhere to record a proof, so every request begins with an empty proof set, every IS_AUTHENTICATED_RECENTLY check fails, and a step-up would redirect forever. The mechanism is inherently session-bound.

For a token-authenticated REST API or a single-page application, that means sudo mode is not available in this form, and no amount of configuration makes it available. What you need instead is a step-up that survives outside a session: a short-lived, elevated credential or scope asserted by the token itself — an auth_time or amr claim in a JWT, an acr value, a re-consent flow, or a one-time elevation token issued after a fresh challenge and required by the sensitive endpoint. Your API then answers a stale elevation with a challenge of its own — 401 with a body describing what is needed, or a dedicated 403 error code your client knows how to react to — rather than with a redirect. The redirect-shaped answer is a browser-session answer, and it is worth saying plainly that Symfony's implementation is a browser-session feature. The design work for stateless APIs is not configuring this option; it is choosing an equivalent and making it verifiable server-side.

What this replaces

Before 8.2 the usual implementation was a session flag — a sudo_until timestamp written at login and checked by a listener — plus a controller to ask for the password again, plus a return-path mechanism, plus the discipline to apply all of it consistently to every sensitive route. It works, and I have written it. What it does not have is a definition: nothing in the framework knows what "recently" means, so every project invents it, and the version that ends up on the sensitive routes is the version somebody remembered to apply.

What 8.2 adds is a definition with an implementation behind it. The window is configuration with defaults, the freshness test is a named attribute that composes with roles and expressions, remember-me sessions are excluded by construction rather than by remembering to exclude them, the step-up reuses your existing authenticator, and the resolver is overridable when time alone is not the right criterion.

The two things to carry away are the ones that surprised me while running it. A denied POST is not replayed, so the destructive actions sudo mode is designed for are exactly the ones where you cannot promise a smooth return — plan for the user submitting twice. And a rule deciding several attributes at once is deprecated, which makes roles: [IS_AUTHENTICATED_RECENTLY, ROLE_ADMIN] the worst of both worlds: an OR that quietly drops the freshness requirement for anyone who holds the role, and a dead end that offers no step-up when it does deny. The shape to migrate to is an allow_if expression — or two separate #[IsGranted] attributes when the check is not expressible as one.

Everything in this article was executed, not read: 42 checks, a real kernel, and a clock that moves. Symfony 8.2 is not released yet, so the demo pins the 8.2.x-dev branches of every package it touches — security-core at 1370ce5, security-http at 6ec1693, security-bundle at a40c203, and expression-language at 8b22ce9 for the allow_if check. That last one is worth a note, because prefer-stable sends a plain composer require to the latest stable release instead of the branch — the constraint has to be written explicitly to keep everything on the same line. The repository includes the captured output of the run. If you reproduce something different on the released version, that is the place to say so.