Two-factor authentication with Symfony 8.2: one authenticator, one voter, and that's it
Until now, two-factor authentication in Symfony meant either a bundle, scheb/2fa-bundle, or home-made code that worked around the firewall. The most common workaround looks like this: after the password, the user is logged in; a second authenticator checks the code and adds a role such as ROLE_2FA_SUCCEED to the token; a subscriber redirects users without that role to the code form.
It works, but it is fragile. The subscriber only protects the routes it is given, and any page you forget opens with the password alone. The role added to the token does not exist on the user: with an entity that does not implement EquatableInterface, as is the case with the one make:user generates, the ContextListener compares the roles of the refreshed user with those of the token, finds them different, and clears the token on the next request.
Symfony 8.2, expected at the end of November, makes this much simpler. The token now remembers how the user proved their identity, and a voter can ask the firewall for a new proof instead of answering 403. With these two building blocks, SMS two-factor authentication fits in an authenticator, a voter, an entry point, the service that sends the code and a few lines of security.yaml. The code in this article runs on the development version of Symfony 8.2, with functional tests.
The idea: two proofs, not a role
I introduced these features in Being logged in is not enough: asking for proof again before sensitive actions. In short: an authenticator states the method it checks with an AuthenticationMethodBadge (pwd, sms, otp…, the names from RFC 8176), and Symfony stores the method and the time on the token:
$token->getAuthenticationProofs(); // ['pwd' => 1793050000, 'sms' => 1793050042]
Here we use them for something else: not to protect one action, but the whole application, right from login.
The key point for two-factor authentication: when the same user authenticates a second time in the session, Symfony adds the new proof to those of the previous token instead of replacing them. That is exactly the two-factor flow: the password first, the code next, and a token that carries both.
form_login and json_login already state pwd. All that is left is to write the authenticator for the code, and to tell the firewall that protected pages require both proofs.
The configuration
# config/packages/security.yaml security: firewalls: main: lazy: true provider: app_user_provider form_login: login_path: app_login check_path: app_login enable_csrf: true # The second factor: one more authenticator on the same firewall. custom_authenticators: - App\Security\SmsCodeAuthenticator # When the voter asks for a proof, this entry point decides which one. re_authentication_entry_point: App\Security\AskForMissingProof # Limits password and code attempts (symfony/rate-limiter component). login_throttling: max_attempts: 5 interval: '15 minutes' logout: path: app_logout access_control: - { path: ^/login, roles: PUBLIC_ACCESS } - { path: ^/logout, roles: PUBLIC_ACCESS } - { path: ^/verify, roles: IS_AUTHENTICATED_FULLY } - { path: ^/, roles: TWO_FACTORS }
The last line does all the work: the whole application requires TWO_FACTORS, except login, logout and the code form. Protection is closed by default. A page added tomorrow is protected without anyone thinking about it.
The code form requires IS_AUTHENTICATED_FULLY, not PUBLIC_ACCESS: you must have given your password in this session to receive a code, a "remember me" cookie is not enough.
TWO_FACTORS is not a role: it is an attribute decided by a voter.
The voter: both proofs, or a new request
// src/Security/Voter/TwoFactorsVoter.php namespace App\Security\Voter; use App\Entity\User; use Symfony\Component\Security\Core\Authentication\AuthenticationMethod; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; final class TwoFactorsVoter extends Voter { public const ATTRIBUTE = 'TWO_FACTORS'; protected function supports(string $attribute, mixed $subject): bool { return self::ATTRIBUTE === $attribute; } protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool { $proofs = $token->getAuthenticationProofs(); if (isset($proofs[AuthenticationMethod::PASSWORD], $proofs[AuthenticationMethod::SMS])) { return true; } // A new proof only helps users who can provide one: without a phone, it's a 403. $user = $token->getUser(); if ($user instanceof User && null !== $user->getPhoneNumber()) { $vote?->requestReAuthentication($attribute); } return false; } }
$vote->requestReAuthentication() is the other new feature in 8.2. The voter denies access, but tells the firewall that a new authentication could change its answer. Instead of a 403, the firewall remembers the requested page and calls its re-authentication entry point.
The voter only does this for users who have a phone. For the others, a new form would lead nowhere: a 403 is more honest than a loop. In a real application, this is where you would send them to second-factor enrollment.
The entry point: asking for what is missing
// src/Security/AskForMissingProof.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\AuthenticationMethod; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Http\EntryPoint\ReAuthenticationEntryPointInterface; final class AskForMissingProof implements ReAuthenticationEntryPointInterface { public function __construct( private readonly UrlGeneratorInterface $urls, ) { } public function startReAuthentication(Request $request, TokenInterface $token): Response { // No password (session reopened by "remember me"): start with the password. $route = isset($token->getAuthenticationProofs()[AuthenticationMethod::PASSWORD]) ? 'app_verify' : 'app_login'; return new RedirectResponse($this->urls->generate($route)); } }
Without re_authentication_entry_point, the firewall would use the entry point of form_login, which can re-authenticate too: it would send the user to the login page, and they would type their password again without ever seeing the code form. Ours checks which proof is missing.
The "remember me" cookie is handled along the way: a session reopened by that cookie leaves no proof, Symfony explicitly excludes it. The voter therefore denies it, and the entry point sends the user to the password first.
The code authenticator
// src/Security/SmsCodeAuthenticator.php namespace App\Security; use Symfony\Bundle\SecurityBundle\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\AuthenticationMethod; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; use Symfony\Component\Security\Http\Authenticator\InteractiveAuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\AuthenticationMethodBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\CustomCredentials; use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\SecurityRequestAttributes; use Symfony\Component\Security\Http\Util\TargetPathTrait; final class SmsCodeAuthenticator extends AbstractAuthenticator implements InteractiveAuthenticatorInterface { use TargetPathTrait; public function __construct( private readonly Security $security, private readonly SmsCodes $codes, private readonly UrlGeneratorInterface $urls, ) { } public function supports(Request $request): ?bool { return $request->isMethod('POST') && 'app_verify' === $request->attributes->get('_route'); } public function authenticate(Request $request): Passport { // The code completes the password: on its own, it logs nobody in. $user = $this->security->getUser() ?? throw new CustomUserMessageAuthenticationException('Log in with your password first.'); return new Passport( new UserBadge($user->getUserIdentifier()), new CustomCredentials( fn (string $code): bool => $this->codes->verify($code), $request->getPayload()->getString('code'), ), [ new CsrfTokenBadge('verify', $request->getPayload()->getString('_csrf_token')), new AuthenticationMethodBadge(AuthenticationMethod::SMS), ], ); } // Only an interactive authentication leaves a proof on the token. public function isInteractive(): bool { return true; } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { $target = $this->getTargetPath($request->getSession(), $firewallName); $this->removeTargetPath($request->getSession(), $firewallName); return new RedirectResponse($target ?? $this->urls->generate('app_account')); } public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { $request->getSession()->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, $exception); return new RedirectResponse($this->urls->generate('app_verify')); } }
Three details matter:
InteractiveAuthenticatorInterface. Without it, the badge is ignored: Symfony only records proofs from an interactive authentication, one where the user has just done something. It is the most likely omission, and it raises no error: the code is accepted, but thesmsproof never shows up, and the user is sent back to the form in a loop.- The user comes from the session. The authenticator does not read an identifier from the request: it reuses the one the password already authenticated. A code alone logs nobody in.
- The passport has a
UserBadge. That is what letslogin_throttlingcount code attempts too: on the sixth attempt within fifteen minutes, even the right code is refused. Without a limit, a script eventually hits the right one among the million possible codes.
The code itself
The service that sends and checks the code has nothing specific to Symfony 8.2. It goes through the Notifier component (symfony/notifier), with your provider's SMS transport. The code is random, only its hash is kept in the session along with an expiry, and it can be used only once.
// src/Security/SmsCodes.php namespace App\Security; use App\Entity\User; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Notifier\Message\SmsMessage; use Symfony\Component\Notifier\TexterInterface; final class SmsCodes { private const KEY = '_sms_code'; private const LIFETIME = 300; public function __construct( private readonly TexterInterface $texter, private readonly RequestStack $requests, ) { } public function send(User $user): void { $code = sprintf('%06d', random_int(0, 999999)); // In the session, only the hash of the code and its expiry. $this->requests->getSession()->set(self::KEY, [ 'hash' => hash('sha256', $code), 'expires' => time() + self::LIFETIME, ]); $this->texter->send(new SmsMessage($user->getPhoneNumber(), "Your login code: $code")); } public function verify(string $code): bool { $session = $this->requests->getSession(); $expected = $session->get(self::KEY); if (null === $expected || $expected['expires'] < time()) { return false; } if (!hash_equals($expected['hash'], hash('sha256', $code))) { return false; } $session->remove(self::KEY); // single use return true; } public function isPending(): bool { $expected = $this->requests->getSession()->get(self::KEY); return null !== $expected && $expected['expires'] >= time(); } }
The controller displays the form and only sends an SMS if no valid code is already pending. One SMS per page refresh means a growing bill, and an open door for anyone who wants to flood a phone number. For the same reason, a "Resend code" button should go through a rate limiter, for example one resend per minute.
// src/Controller/SecurityController.php (excerpt) #[Route('/verify', name: 'app_verify', methods: ['GET', 'POST'])] public function verify( #[CurrentUser] User $user, SmsCodes $codes, AuthenticationUtils $authenticationUtils, ): Response { // No phone, nothing to send: in a real application, redirect to enrollment. if (null === $user->getPhoneNumber()) { throw $this->createAccessDeniedException(); } // The POST is handled by SmsCodeAuthenticator. if (!$codes->isPending()) { $codes->send($user); } return $this->render('security/verify.html.twig', [ 'error' => $authenticationUtils->getLastAuthenticationError(), ]); }
The flow, end to end
- Ada requests
/account. She is not logged in:form_loginsends her to/login. - She gives her password. The token carries
pwd, and the firewall takes her back to/account. - The voter denies
TWO_FACTORSand asks for a new proof. The firewall remembers/account, and the entry point sends her to/verify, which sends her a code. - She types the code. The token carries
pwdandsms, and she is back on/account.
The voter does not look at the time of the proofs: once the code is given, it is valid for the whole session, as in most two-factor setups, and logging out clears everything. Since the time is stored, nothing stops you from requiring an sms proof less than 24 hours old.
No more subscriber, no more role added to the token, no more list of routes to keep up to date. The user keeps their usual roles, and an entity without EquatableInterface is not a problem: the tests of the example use a User class that does not implement it, and the session survives from one request to the next.
Before you adopt it
This article shows the mechanism, not a complete two-factor system. Three things to know.
SMS is one of the weakest second factors. It is exposed to SIM swapping, and NIST classifies it as a "restricted" authenticator. The same pattern works with an authenticator app: the authenticator checks a TOTP code with a library such as spomky-labs/otphp, states AuthenticationMethod::ONE_TIME_PASSWORD, and the voter requires otp instead of sms. You then need one secret per account, saved at enrollment after a first code is confirmed, and never shown again.
Enrollment and recovery are up to you. Registering the phone (or the TOTP secret), offering hashed, single-use backup codes, and protecting the deactivation of two-factor authentication with a recent proof. For that last point, IS_AUTHENTICATED_RECENTLY and the same re-authentication mechanism do the job: I covered it in Being logged in is not enough: asking for proof again before sensitive actions.
A limit of the mechanism. Re-authentication only kicks in when a single attribute is denied. An access_control rule that lists several attributes in roles always gives a 403: keep TWO_FACTORS alone on its line. Passing several attributes to the same decision is deprecated in 8.2 anyway.
If you want all of this ready to use, or cannot wait for Symfony 8.2, scheb/2fa-bundle is still the right choice: it brings TOTP, backup codes, trusted devices and codes by email. But if what you need is a second factor on an application you control, Symfony 8.2 lets you write it in under two hundred lines, without working around the firewall.