diff --git a/app/Livewire/Auth/TwoFactorBackup.php b/app/Livewire/Auth/TwoFactorBackup.php
new file mode 100644
index 0000000..856e6a5
--- /dev/null
+++ b/app/Livewire/Auth/TwoFactorBackup.php
@@ -0,0 +1,55 @@
+has('2fa.user')) {
+ return $this->redirect(route('login'), navigate: true);
+ }
+ }
+
+ public function verify()
+ {
+ $this->validate();
+
+ $this->assertNotRateLimited();
+
+ $user = User::find(session('2fa.user'));
+
+ if (! $user || ! $user->hasTwoFactorEnabled()) {
+ session()->forget(['2fa.user', '2fa.remember']);
+ throw ValidationException::withMessages(['code' => __('auth.session_expired')]);
+ }
+
+ // Backup-only view: a one-time recovery code is the sole accepted credential (no TOTP).
+ if (! $user->useRecoveryCode($this->code)) {
+ $this->hitRateLimit();
+ throw ValidationException::withMessages(['code' => __('auth.invalid_code')]);
+ }
+
+ $this->clearRateLimit();
+
+ return $this->completeLogin($user);
+ }
+
+ public function render()
+ {
+ return view('livewire.auth.two-factor-backup')->title(__('auth.title_challenge'));
+ }
+}
diff --git a/resources/views/livewire/auth/two-factor-backup.blade.php b/resources/views/livewire/auth/two-factor-backup.blade.php
new file mode 100644
index 0000000..6e97073
--- /dev/null
+++ b/resources/views/livewire/auth/two-factor-backup.blade.php
@@ -0,0 +1,39 @@
+@php
+ $fieldText = 'h-12 w-full rounded-md border border-line bg-inset px-3 text-center font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
+ $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
+ $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
+@endphp
+
+
+
+
{{ __('auth.two_factor') }}
+
{{ __('auth.backup_heading') }}
+
{{ __('auth.backup_subtitle') }}
+
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php
index 9f641be..5f15e58 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -47,6 +47,7 @@ Route::middleware('guest')->group(function () {
Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request');
Route::get('/reset-password/{token}', Auth\ResetPassword::class)->name('password.reset');
Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge');
+ Route::get('/two-factor-challenge/backup', Auth\TwoFactorBackup::class)->name('two-factor.challenge.backup');
});
Route::middleware('auth')->group(function () {
diff --git a/tests/Feature/TwoFactorBackupTest.php b/tests/Feature/TwoFactorBackupTest.php
new file mode 100644
index 0000000..96b83f8
--- /dev/null
+++ b/tests/Feature/TwoFactorBackupTest.php
@@ -0,0 +1,146 @@
+create([
+ 'two_factor_secret' => (new Google2FA)->generateSecretKey(),
+ 'two_factor_confirmed_at' => now(),
+ ]);
+ }
+
+ private function keyOnlyUser(): User
+ {
+ $u = User::factory()->create();
+ WebauthnCredential::create(['user_id' => $u->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]);
+
+ return $u->fresh();
+ }
+
+ public function test_backup_code_logs_in_and_is_consumed(): void
+ {
+ $user = $this->enrolledUser();
+ $code = $user->replaceRecoveryCodes()[0];
+ session()->put('2fa.user', $user->id);
+
+ Livewire::test(TwoFactorBackup::class)
+ ->set('code', $code)
+ ->call('verify')
+ ->assertRedirect(route('dashboard'));
+
+ $this->assertAuthenticatedAs($user);
+ $this->assertFalse($user->fresh()->useRecoveryCode($code)); // consumed
+ }
+
+ public function test_invalid_backup_code_fails(): void
+ {
+ $user = $this->enrolledUser();
+ $user->replaceRecoveryCodes();
+ session()->put('2fa.user', $user->id);
+
+ Livewire::test(TwoFactorBackup::class)
+ ->set('code', 'nope-nope')
+ ->call('verify')
+ ->assertHasErrors('code');
+
+ $this->assertGuest();
+ }
+
+ public function test_a_totp_code_is_not_accepted_on_the_backup_view(): void
+ {
+ $user = $this->enrolledUser();
+ $user->replaceRecoveryCodes();
+ $totp = (new Google2FA)->getCurrentOtp($user->two_factor_secret);
+ session()->put('2fa.user', $user->id);
+
+ Livewire::test(TwoFactorBackup::class)
+ ->set('code', $totp)
+ ->call('verify')
+ ->assertHasErrors('code');
+
+ $this->assertGuest();
+ }
+
+ public function test_requires_a_pending_2fa_session(): void
+ {
+ Livewire::test(TwoFactorBackup::class)
+ ->assertRedirect(route('login'));
+ }
+
+ public function test_field_and_login_link_render(): void
+ {
+ $user = $this->enrolledUser();
+ $user->replaceRecoveryCodes();
+ session()->put('2fa.user', $user->id);
+
+ Livewire::test(TwoFactorBackup::class)
+ ->assertSee(__('auth.backup_heading'))
+ ->assertSee(__('auth.challenge_backup_placeholder')) // the backup field is present
+ ->assertSee(__('auth.back_to_login'));
+ }
+
+ public function test_back_to_options_shown_for_a_totp_user(): void
+ {
+ $user = $this->enrolledUser();
+ $user->replaceRecoveryCodes();
+ session()->put('2fa.user', $user->id);
+
+ Livewire::test(TwoFactorBackup::class)
+ ->assertSee(__('auth.back_to_options'));
+ }
+
+ public function test_back_to_options_hidden_for_key_only_without_secure_context(): void
+ {
+ // No domain configured → webauthnAvailable() false, pendingHasTotp false: backup is the
+ // only path, so the "back to options" link would loop straight back here and is hidden.
+ $user = $this->keyOnlyUser();
+ $user->replaceRecoveryCodes();
+ session()->put('2fa.user', $user->id);
+
+ Livewire::test(TwoFactorBackup::class)
+ ->assertDontSee(__('auth.back_to_options'))
+ ->assertSee(__('auth.back_to_login'));
+ }
+
+ public function test_failed_backup_attempts_share_the_rate_limit_with_the_main_challenge(): void
+ {
+ $user = $this->enrolledUser();
+ $codes = $user->replaceRecoveryCodes();
+ session()->put('2fa.user', $user->id);
+
+ // 5 wrong backup attempts trip the per-(user+IP) bucket (5/60s).
+ for ($i = 0; $i < 5; $i++) {
+ Livewire::test(TwoFactorBackup::class)->set('code', 'wrong-'.$i)->call('verify');
+ }
+
+ // A genuinely valid, unused backup code is now refused on the MAIN challenge by the
+ // SAME shared bucket — proving one counter spans both components.
+ Livewire::test(TwoFactorChallenge::class)
+ ->set('code', $codes[0])
+ ->call('verify')
+ ->assertHasErrors('code');
+
+ $this->assertGuest();
+ }
+}