scheb/2fa
scheb/2fa adds two-factor authentication to Symfony applications with TOTP authenticator apps, email codes, backup codes, and trusted-device support. It integrates cleanly with Symfony Security and provides extensible, well-tested building blocks for securing user logins.
Reality check first: despite the "framework-agnostic core" wording, this package is Symfony-first in practice. The core (scheb/2fa) is just interfaces + the authentication-context state machine; the actual wiring — firewall listener, form entry point, Twig templates, session token swap — lives in scheb/2fa-bundle. If you're on Laravel, you'll get more value from Fortify's built-in 2FA or pragmarx/google2fa + bacon/bacon-qr-code; you can still steal the scheb/2fa-totp code validator, but you'll be reimplementing the flow. On Symfony, everything below applies directly.
Install the bundle plus only the providers you actually need (they're separate packages so you don't drag in email/QR dependencies you won't use):
composer require scheb/2fa-bundle scheb/2fa-totp scheb/2fa-backup-code scheb/2fa-trusted-device
Enable Scheb\TwoFactorBundle\SchebTwoFactorBundle (Flex does this for you), then look at these three files in this order — they are the whole integration surface:
config/packages/scheb_2fa.yaml — which providers are enabled, templates, issuer name, cookie lifetime.config/packages/security.yaml — the two_factor block on your firewall and security_tokens.User entity — the Model\Totp\TwoFactorInterface / Model\Email\TwoFactorInterface / Model\BackupCode\BackupCodeInterface implementations.Minimal configuration:
# config/packages/scheb_2fa.yaml
scheb_two_factor:
security_tokens:
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
totp:
enabled: true
issuer: "MyApp"
template: security/2fa_form.html.twig
backup_codes:
enabled: true
# config/packages/security.yaml
security:
firewalls:
main:
form_login: { login_path: app_login, check_path: app_login }
two_factor:
auth_form_path: /2fa # GET: render the code form
check_path: /2fa_check # POST: validate the code
default_target_path: app_home
First use case to get working end-to-end: TOTP enrolment + verification. Add getTotpAuthenticationSecret() / getTotpAuthenticationUsername() / isTotpAuthenticationEnabled() to your user, build a small controller that generates a Base32 secret, renders a QR code (otpauth://totp/{issuer}:{username}?secret=...&issuer=...), asks the user to confirm one code before persisting, then let the normal login flow take over. Note the bundle does not generate secrets for you — that's your job (spomky-labs/otphp or pragmarx/google2fa).
Interface-per-factor on the user entity. Each provider is driven by a marker interface on your user. Keep them thin and delegate to your own services:
public function isTotpAuthenticationEnabled(): bool
{
return $this->totpSecret !== null && $this->totpConfirmedAt !== null;
}
public function getTotpAuthenticationUsername(): string
{
return $this->getUserIdentifier();
}
The is…Enabled() method is the enforcement switch — returning false for a given user lets them through with password only. Use it for gradual rollout, admin exemptions, or "user hasn't finished enrolment yet" states.
Enrolment as a separate flow from login. The login flow only ever validates. Enrolment (secret generation → QR → confirm → persist → generate backup codes → show once) should be a normal authenticated controller under /account/2fa. Gate it behind IS_AUTHENTICATED_FULLY, and treat the confirm step as a write operation, not as part of the login listener.
Custom providers (SMS, WebAuthn, push). Implement TwoFactorProviderInterface — prepareAuthentication(), validateAuthenticationCode(), isTwoFactorAuthEnabledForUser(), plus the form template — and tag the service scheb_two_factor.provider. That's the single extension point that covers 90% of "we need a different second factor" requests. prepareAuthentication() is where you send the SMS/email and stash the one-time code in the session or a cache.
Workflow hooks for policy. The bundle fires a require event before showing the form and a check event after validation. Grab the AuthenticationContext to inspect the token, request, and firewall name, and call skipTwoFactor() for allow-listed situations (trusted office IP range, service accounts, impersonation). This is the cleanest place to implement "admins must use 2FA, contractors must not" without touching every user object.
Trusted devices. Implement TrustedDeviceInterface (you need getTrustedVersion() returning an integer) and enable trusted_device in config. The bundle sets a signed cookie (lifetime, default 60 days) and the template renders a "remember this device" checkbox named _trusted. Bump trustedVersion when the user changes their password or disables 2FA to invalidate every remembered device at once — that's the whole revocation story, and forgetting it is a real security bug.
Backup codes are your recovery path. Generate ~10 codes at enrolment, show them exactly once, and store them on the user. Implement getBackupCodes() and replaceBackupCode($index); the bundle calls the latter when a code is consumed. Make sure the mutation actually gets flushed (via your persister/Doctrine listener) or the same code stays valid forever.
Testing. Write functional tests that walk the real login form, then assert on the redirect to auth_form_path. Don't rely on loginUser() — it bypasses the firewall's authentication flow entirely and will silently skip 2FA.
TwoFactorToken, not your usual PostAuthenticationToken. Anything doing isGranted('ROLE_USER') or access_control will behave differently mid-flow. Grant access to the 2FA routes using the IS_AUTHENTICATED_2FA_IN_PROGRESS attribute (TwoFactorInProgressVoter), or you'll lock users out of the very page that finishes their login.security_tokens must match your actual token class. If you use a custom authenticator or an older Symfony with UsernamePasswordToken, list it there. Symptom of a mismatch: login "succeeds" but 2FA is never triggered — a silent, dangerous no-op.check_path must be reachable unauthenticated-ish. It sits inside the firewall but must be exempt from your normal access_control rules, same as the login check path._auth_code (or whatever auth_code_parameter_name is set to) and include the firewall's CSRF token if CSRF protection is on. A wrong field name shows the form again with no error, which is maddening to debug./2fa_check keyed on user + IP, and consider a lockout after N failures.window (leeway in time steps); bump it to 1–2 before you start rewriting your validator.getBackupCodes() returns the codes. If your security policy requires hashed-at-rest codes, you'll need a custom provider rather than the built-in one.scheb_2fa.yaml or the firewall block, run bin/console cache:clear — otherwise you'll spend twenty minutes debugging a change that isn't loaded.two_factor on each relevant firewall, and the pending token remembers its firewall name. A user authenticated on admin won't have 2FA state on main.IS_AUTHENTICATED_FULLY is not enough. After 2FA, prefer a dedicated role/attribute check in access_control for sensitive routes so that a half-authenticated session can't reach them via any other path.scheb/2fa-* packages to the same minor version and read the UPGRADE notes between majors — the firewall config shape and the AuthenticationContext API have changed across 5.x → 8.x.How can I help you explore Laravel packages today?