Two-Factor Authentication in Laravel: TOTP Done Without Locking People Out
Password reset works on knowledge of an email address, which is why every breach starts there. TOTP adds a second factor, a rotating code from an app like Google Authenticator or 1Password, and the implementation is genuinely simple in Laravel. What makes or breaks the feature is everything around the code itself: how secrets get provisioned, what happens on clock drift, and whether losing the phone means losing the account.
Secrets: generate once, store encrypted, confirm before enabling
The secret lives with Authenticator apps as an otpauth:// URI, usually delivered as a QR code. Three rules make the flow safe:
- Generate the secret server-side with a library like
paragonie/constant_time_encodingpluschristopherthomasdev/totp, or the Fortify/Rawilk packages that wrap the whole flow. Never trust a client to generate it. - Store the secret encrypted, the same way you store API credentials. A leaked TOTP secret converts 2FA back into 1FA.
- Do not enable 2FA until the user proves they have the code. Ask for one valid code at setup. If you skip this step, users scan the QR, close the window, and discover at next login that they locked themselves out.
The login flow: verify the password first, the code second
Treat the code as a second step with its own rate limit, not a checkbox on the login form. The sequence that holds up in practice:
- Email and password verified in the normal way; the session gets a "partially authenticated" state.
- The code step asks for a 6-digit code, again with rate limiting, and accepts a recovery code in the same field. Nobody remembers which of the two they saved where, and a field that says "recovery code" teaches users to type an authenticator code into the wrong box.
- Only after a valid code does the session become fully authenticated.
Rate limiting per account and per IP matters here. A code is six digits, so an unthrottled attacker has a million candidates to cycle through; at an honest five attempts per minute, brute force stops being a plan.
Clock drift: the window is wider than you think
TOTP codes rotate every 30 seconds, and the code the user just read may already be past its edge by the time it arrives. Accept a window of one period on each side (the +/- 1 tick check). That single setting eliminates most "the code is always invalid" support tickets, which usually come from users who typed the code at second 29 of their app's cycle.
Recovery codes: single use, hashed, and shown exactly once
At enablement, generate a set of recovery codes, display them once, and make the user confirm they saved them before you finish. Store them hashed like passwords and burn each code on first use. If a code can be reused, the recovery codes become a permanent second password that never rotates.
Give a "regenerate codes" button that invalidates old ones, and log recovery-code use the same way you log password changes. A successful recovery-code login often means the phone is gone, and a notification at that moment is worth more than any config setting.
Remember device, but scope it
Asking for a code on every login on a personal laptop gets 2FA disabled by the same users you wanted protected. A remember-device cookie, bound to the user and a random token, that skips the code step for 30 days is the standard compromise. Rotate the token when the password changes or the account is suspicious (new IP range, password reset), so the remembered device can outlive compromise only until it matters.
The feature is one screen of settings, three screens of login, and a table of recovery codes. The discipline lives in the edges: what you allow, what you burn, and what you re-check.