TOTP two-factor auth in Symfony, done properly: scheb/2fa, backup codes, and the four mistakes everyone makes
A production 2FA setup with scheb/2fa-bundle: confirmed activation, hashed single-use backup codes, and the design decisions that separate a checkbox from a security feature.
Two-factor authentication is the feature every B2B prospect's security questionnaire asks about, and one of the easiest to implement badly — because the happy path works in an afternoon and every failure mode hides in the edges. This is the design ShipAnvil ships, built on the excellent scheb/2fa bundle, and the four edges that matter.
The baseline wiring
scheb/2fa-bundle with the TOTP package plugs into the Symfony firewall:
# config/packages/scheb_2fa.yaml
scheb_two_factor:
security_tokens:
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
totp:
enabled: true
issuer: '%env(APP_NAME)%'
Your user implements TwoFactorInterface — essentially "do you have 2FA
on, and what's your TOTP secret". After a successful password (or magic
link) login, the firewall holds the session in a half-authenticated state
until a valid code arrives. The bundle handles the state machine; your
job is the lifecycle around it. That's where the mistakes live.
Mistake 1: activating 2FA before the user proves their authenticator works
The naive flow — generate secret, show QR code, flip totp_enabled = true
— locks out every user whose phone clock is skewed, who scanned the wrong
QR, or who closed the tab. They find out at their next login.
The fix: the secret is stored but inactive until the user confirms a first valid code:
public function confirmSetup(User $user, string $code): bool
{
// The secret was generated and stored at setup-start, but
// isTotpAuthenticationEnabled() still returns false.
if (!$this->totpVerifier->isValid($user, $code)) {
return false;
}
$user->activateTotp(); // only now does login require a code
$this->generateBackupCodes($user);
return true;
}
Setup that can't be completed can't lock anyone out — abandoning the page leaves the account exactly as it was.
Mistake 2: backup codes you can read in the database
Backup codes are passwords. Treat them like passwords:
- generate them server-side (8–10 codes, high entropy),
- store only hashes (SHA-256 is fine here — the input space is random, not human-chosen),
- show the plaintext exactly once, at generation,
- invalidate each code after use, and offer one-click regeneration (which voids the previous set).
public function consumeBackupCode(User $user, string $code): bool
{
$hash = hash('sha256', $code);
if (!$user->hasBackupCodeHash($hash)) {
return false;
}
$user->removeBackupCodeHash($hash); // single use, enforced in code
return true;
}
A database leak should yield nothing replayable. If your backup codes are stored in clear, you've built a second password column without the hashing.
Mistake 3: forgetting that disabling is also a security event
When a user turns 2FA off, wipe the secret and the backup codes — don't keep them around "in case they re-enable". A stale secret that silently reactivates is an attacker's dream; re-enabling must mean re-running the full confirmed setup. And both enabling and disabling deserve the same CSRF-protected POST treatment as a password change — "disable my 2FA" is precisely the action a session hijacker wants.
Mistake 4: 2FA on the form but not on the other doors
Audit every authentication path. Password login — covered. But:
- Magic links: a login link that bypasses the 2FA step downgrades your whole setup to one factor. scheb/2fa intercepts any firewall authentication if configured for it — verify it actually fires on your link path (write the functional test: request the link, follow it, assert you land on the 2FA challenge, not the dashboard).
- Remember-me: decide explicitly whether a remembered session skips the code, and for how long. Convenient, defensible — but it should be a decision, not a default you discovered later.
- Admin impersonation: impersonating a 2FA-enabled user should not prompt you for their code (you have your own session), but the impersonation entry point itself must sit behind your admin's auth.
The test suite is what makes this auditable: ShipAnvil ships functional tests that try each door with 2FA enabled and assert the challenge appears. When a refactor accidentally opens a path, CI says so — not a pentest six months later.
The lifecycle in one picture
setup start ──► secret stored (inactive) ──► first valid code
│
backup codes ◄──────┘ (hashed, shown once)
│
login (password / magic link) ──► 2FA challenge (TOTP or backup code)
│
disable (POST + CSRF) ──► secret AND codes wiped — re-setup from zero
All of it — entities, controllers, the account-page UX, and the functional tests for every edge above — ships wired in ShipAnvil, alongside the rest of the auth module. This site runs the kit: create an account and walk the setup flow yourself — QR code, confirmation, backup codes shown once, the lot.