Two-factor authentication is one of those features that looks finished long before it actually is. Generating a QR code and checking a six-digit number is perhaps a third of the work. The rest — recovery codes, the half-authenticated session, rate limiting, and making sure the second factor cannot simply be skipped — is what decides whether it protects anyone.
This guide covers a complete implementation in Laravel. Versions at the time of writing: Laravel 12/13, Fortify 1.37, google2fa-laravel 3.0 and bacon-qr-code 3.1.
What 2FA actually protects against
Be clear about the threat model, because it determines which method is worth implementing.
2FA defends against stolen credentials — passwords leaked in a breach, reused across sites, or guessed. That is a very common attack and 2FA stops it cold.
TOTP does not defend against a convincing phishing page. If a user is tricked into entering their password and their six-digit code on an attacker's site, the attacker can relay both in real time. Only origin-bound factors — passkeys and hardware keys — resist that, because the browser refuses to sign for the wrong domain.
So TOTP is a large improvement over passwords alone, and passkeys are a further step. Both are worth having; neither is "done".
Choosing an approach
| Fortify | Custom TOTP | Passkeys / WebAuthn | |
|---|---|---|---|
| Setup effort | Low | Medium | Medium to high |
| Control over UX | Limited by its flow | Total | Total |
| Phishing resistant | No | No | Yes |
| Recovery codes | Built in | You build them | You build them |
| Works offline | Yes | Yes | Device dependent |
| Best when | Standard app, standard flow | You need a bespoke flow | Security is the product |
If your application is reasonably conventional, use Fortify. Write it yourself when you need control Fortify does not give you — a different challenge screen, a different session model, or 2FA that is mandatory for some roles and optional for others.
The fast path: Fortify
composer require laravel/fortify
php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"
php artisan migrate
// config/fortify.php
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]),
],
Setting confirm to true matters more than it looks. Without it, 2FA is switched on the moment the user asks for it — including if they never successfully scan the QR code. They are then locked out of their own account. With confirmation, the user must prove one working code before the factor becomes required.
Fortify then exposes routes for enabling, confirming and disabling 2FA, and for the challenge itself.
Building it yourself
The rest of this guide covers a hand-rolled implementation, because that is where the decisions live.
composer require pragmarx/google2fa-laravel bacon/bacon-qr-code
// database/migrations/xxxx_add_two_factor_to_users.php
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')->nullable();
$table->text('two_factor_recovery_codes')->nullable();
$table->timestamp('two_factor_confirmed_at')->nullable();
});
}
Both secret columns are text because they will hold encrypted values, which are far longer than the raw data. Encrypt them with a cast so the plaintext never touches the database:
// app/Models/User.php
protected function casts(): array
{
return [
'two_factor_secret' => 'encrypted',
'two_factor_recovery_codes' => 'encrypted:array',
'two_factor_confirmed_at' => 'datetime',
];
}
public function hasTwoFactorEnabled(): bool
{
return ! is_null($this->two_factor_secret)
&& ! is_null($this->two_factor_confirmed_at);
}
Note that hasTwoFactorEnabled() requires both a secret and a confirmation. A user mid-enrolment is not protected yet, and must not be challenged as though they were.
Enrolment: secret and QR code
use PragmaRX\Google2FA\Google2FA;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
public function create(Request $request)
{
$google2fa = new Google2FA();
$secret = $google2fa->generateSecretKey();
// Held in the session until confirmed — an unconfirmed secret must not
// be persisted, or a half-finished enrolment can lock the user out.
$request->session()->put('2fa.pending_secret', $secret);
$uri = $google2fa->getQRCodeUrl(
config('app.name'),
$request->user()->email,
$secret
);
$writer = new Writer(new ImageRenderer(
new RendererStyle(232),
new SvgImageBackEnd()
));
return view('account.two-factor.setup', [
'qrSvg' => $writer->writeString($uri),
'secret' => $secret, // shown so the user can enter it manually
]);
}
Always show the secret as text alongside the QR code. Some users are on the same device as the authenticator and cannot photograph their own screen.
Confirming enrolment
public function store(Request $request)
{
$request->validate(['code' => ['required', 'digits:6']]);
$secret = $request->session()->get('2fa.pending_secret');
abort_if(! $secret, 419);
$google2fa = new Google2FA();
// window: 1 accepts the adjacent 30-second steps, which covers ordinary
// clock drift between the user's phone and the server.
if (! $google2fa->verifyKey($secret, $request->code, 1)) {
throw ValidationException::withMessages([
'code' => __('That code is not valid. Check your device clock and try again.'),
]);
}
$user = $request->user();
$recovery = collect(range(1, 8))->map(fn () => Str::random(10).'-'.Str::random(10));
$user->forceFill([
'two_factor_secret' => $secret,
'two_factor_recovery_codes' => $recovery->all(),
'two_factor_confirmed_at' => now(),
])->save();
$request->session()->forget('2fa.pending_secret');
return view('account.two-factor.recovery-codes', ['codes' => $recovery]);
}
Show the recovery codes exactly once, immediately after enrolment, and make them downloadable. A user who loses their phone without them has no route back that does not involve you manually verifying their identity.
The challenge: the part most implementations get wrong
After a correct password, the user is half authenticated. They must not be logged in yet — but you need to remember who they are while they fetch their code.
The mistake is calling Auth::login() and then redirecting to a challenge screen. At that point the user is genuinely authenticated, and anything that skips the redirect — typing a URL directly, an API call, a stale tab — bypasses 2FA entirely.
Keep them out of the guard instead:
public function authenticate(LoginRequest $request)
{
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages(['email' => __('auth.failed')]);
}
if ($user->hasTwoFactorEnabled()) {
// Not logged in. Only a pointer to the pending challenge.
$request->session()->put('2fa.challenge_user', $user->id);
$request->session()->put('2fa.challenge_at', now()->timestamp);
return redirect()->route('two-factor.challenge');
}
Auth::login($user, $request->boolean('remember'));
$request->session()->regenerate();
return redirect()->intended();
}
public function verify(Request $request)
{
$request->validate(['code' => ['required', 'string']]);
$userId = $request->session()->get('2fa.challenge_user');
$startedAt = $request->session()->get('2fa.challenge_at');
// Expire an abandoned challenge rather than leaving it open indefinitely.
abort_if(! $userId || now()->timestamp - $startedAt > 300, 419);
$key = '2fa:'.$userId;
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'code' => __('Too many attempts. Try again in :s seconds.', [
's' => RateLimiter::availableIn($key),
]),
]);
}
$user = User::findOrFail($userId);
if (! $this->codeIsValid($user, $request->code)) {
RateLimiter::hit($key, 300);
throw ValidationException::withMessages(['code' => __('That code is not valid.')]);
}
RateLimiter::clear($key);
Auth::login($user);
$request->session()->regenerate();
$request->session()->forget(['2fa.challenge_user', '2fa.challenge_at']);
return redirect()->intended();
}
Rate limiting is not optional. Six digits is a million combinations, which sounds like plenty until you realise an unthrottled endpoint can be hammered thousands of times a second. Five attempts per five minutes reduces that to nothing.
Recovery codes, used correctly
private function codeIsValid(User $user, string $code): bool
{
if (preg_match('/^\d{6}$/', $code)) {
return (new Google2FA())->verifyKey($user->two_factor_secret, $code, 1);
}
$codes = collect($user->two_factor_recovery_codes ?? []);
$match = $codes->first(fn ($stored) => hash_equals($stored, $code));
if (! $match) {
return false;
}
// Single use — burn it immediately.
$user->forceFill([
'two_factor_recovery_codes' => $codes->reject(fn ($c) => $c === $match)->values()->all(),
])->save();
return true;
}
Two details matter here. Use hash_equals rather than === so comparison time does not leak information. And remove the code on use — a recovery code that still works after being used is just a weaker password.
Mistakes that leave 2FA bypassable
- Logging the user in before the challenge. The single most common flaw. If the session is authenticated, the challenge is decorative.
- Forgetting other entry points. API tokens, "remember me" cookies, social login, magic links and impersonation all need to respect the second factor, or they become the way around it.
- No rate limit on the code endpoint. Six digits falls quickly to unthrottled brute force.
- Storing the secret in plain text. A database leak then hands over both factors at once.
- Not regenerating the session after login. Leaves you open to session fixation.
- Letting password reset disable 2FA. An attacker with mailbox access should not be able to remove the factor protecting against exactly that.
Testing it
public function test_a_user_with_2fa_is_not_logged_in_by_password_alone(): void
{
$user = User::factory()->withTwoFactor()->create();
$this->post('/login', [
'email' => $user->email,
'password' => 'password',
])->assertRedirect(route('two-factor.challenge'));
$this->assertGuest();
}
public function test_a_recovery_code_cannot_be_reused(): void
{
$user = User::factory()->withTwoFactor()->create([
'two_factor_recovery_codes' => ['aaaa-bbbb'],
]);
$this->withSession(['2fa.challenge_user' => $user->id, '2fa.challenge_at' => now()->timestamp])
->post('/two-factor/verify', ['code' => 'aaaa-bbbb'])
->assertRedirect();
$this->post('/logout');
$this->withSession(['2fa.challenge_user' => $user->id, '2fa.challenge_at' => now()->timestamp])
->post('/two-factor/verify', ['code' => 'aaaa-bbbb'])
->assertSessionHasErrors('code');
}
assertGuest() in the first test is the important assertion. It proves the user is genuinely not authenticated during the challenge, which is precisely the property that most broken implementations lack.
Where this is heading: passkeys
TOTP is a solid baseline, but it is phishable. Passkeys are bound to your domain by the browser, so a lookalike site cannot obtain a usable signature no matter how convincing it is.
A pragmatic 2026 position: offer passkeys as the primary factor, keep TOTP as the fallback for users whose devices do not support them, and keep recovery codes underneath both. Do not remove TOTP — device support is good but not universal, and a factor nobody can use is worse than a weaker one everybody can.
Frequently asked questions
Should I use Laravel Fortify or build 2FA myself?
Use Fortify unless you need control it does not offer. It ships enrolment, confirmation, recovery codes and the challenge flow, all maintained by the framework team. Build it yourself when you need a bespoke challenge screen, a different session model, or role-dependent enforcement.
How do I generate a 2FA QR code in Laravel?
Generate a secret with google2fa, build the otpauth URI with getQRCodeUrl(), and render it with bacon-qr-code as an SVG. Always display the secret as text as well, for users whose authenticator is on the same device.
Where should the TOTP secret be stored?
On the user record, encrypted with Laravel's encrypted cast. Never in plain text — a database leak would otherwise expose both factors at once. Use a text column, since ciphertext is much longer than the secret.
How many recovery codes should I issue?
Eight is a common choice. Show them once at enrolment, make them downloadable, invalidate each on use, and let the user regenerate the set after confirming their password.
Why does my 2FA code say invalid when it looks correct?
Almost always clock drift between the user's device and the server. Verify with a window of 1, which accepts the adjacent 30-second steps, and make sure the server's clock is synchronised via NTP.
Can two-factor authentication be bypassed?
Yes, if it is implemented badly. The usual causes are logging the user in before the challenge, forgetting other entry points such as API tokens or magic links, and leaving the code endpoint unthrottled. TOTP is also phishable in real time, which is what passkeys solve.
Should I use SMS for two-factor authentication?
Prefer not to. SMS is vulnerable to SIM swapping and interception, and it depends on the mobile network. A TOTP authenticator app is stronger, free, and works offline. Keep SMS only as a last resort for users who cannot use anything else.
Do passkeys replace TOTP?
Eventually, but not yet. Passkeys resist phishing because the browser binds them to your domain, so they are the better primary factor. Keep TOTP as a fallback while device and browser support remains uneven, with recovery codes beneath both.
Comments (0)
Leave a Comment
No comments yet. Be the first to share your thoughts!