Laravel Jetstream 2FA Recovery Codes Not Generated or Accepted: Diagnostic Guide
Diagnostic guide for Laravel Jetstream when 2FA recovery codes are missing, invisible, or rejected. Covers Fortify feature flags, action bindings, database column issues, and challenge flow checks.
31 Jul 2025, 11:32 UTC

Problem: users enable 2FA but recovery codes are missing, invisible, or rejected
In Jetstream with Fortify, recovery codes are only created at 2FA enable time and stored as hashed JSON in the users table. When the UI shows no codes, the regenerate button does nothing, or a valid-looking code is rejected at the challenge screen, the issue is almost always configuration, data, or binding rather than the authenticator app.
Useful takeaway first: check that codes actually exist in the database for the user and that Fortify is configured to expose recovery code management. If the column is null or the feature flag is off, no amount of UI clicking will help.
Recognizable conditions
- After enabling 2FA, the "Recovery Codes" section is blank or never shown on the profile/security page.
- "Regenerate recovery codes" button loads but does not change anything, or returns a silent error.
- On the 2FA challenge page, "Use a recovery code" is missing, or submitting a code redirects back to login instead of granting access.
- Newly enabled users have
two_factor_secretset butrecovery_codesis NULL.
Cause / diagnostic table
| Symptom | Likely cause | Where to verify |
|---|---|---|
| No codes shown in profile | features.two_factor_authentication.confirm disabled | config/fortify.php |
| Regenerate button does nothing | GenerateTwoFactorRecoveryCodes action not bound | App\Providers\FortifyServiceProvider |
| Codes exist in DB but app says none | Column type/collation mismatch causing json_decode to return null | Schema of users.recovery_codes |
| Code rejected at challenge | Session missing two_factor_authenticated flag or wrong POST field | Route /two-factor-challenge request |
| Codes were present then disappeared after deploy | APP_KEY changed; encrypted columns unreadable on Laravel 10+ | Environment config |
Ordered checks
1. Confirm feature flag and UI path
Recovery code management is gated by Fortify features. Run from project root:
cat config/fortify.php
Check:
'features' => [
Features::twoFactorAuthentication([
'confirm' => true,
]),
],
If confirm is false, the profile page hides the recovery codes section for both Inertia and Livewire stacks. The UI path differs by stack, so verify the published component is used for your stack.
Risk: changing this flag only affects new renders; clear config cache after edit.
php artisan config:clear
2. Verify codes exist and are readable
Check the database directly. The recovery_codes column must contain a JSON array of hashed strings, not plaintext. Do not insert plaintext.
php artisan tinker
Inside tinker, with appropriate DB permissions:
$user = App\Models\User::where('email','[contact removed]')->first();
$codes = $user->recovery_codes;
Expected check: the accessor should return an array or null. If it returns null while the raw column is non-empty, suspect column type or encryption.
Schema check in a MySQL client:
DESCRIBE users recovery_codes;
Column type should be json or text with compatible collation. A mismatch can make Eloquent decode to null, making the system behave as if no codes exist.
3. Confirm action binding for regeneration
Regeneration relies on a Fortify action binding. Open app/Providers/FortifyServiceProvider.php and verify boot method contains:
use Laravel\Fortify\Fortify;
use App\Actions\Fortify\GenerateTwoFactorRecoveryCodes;
public function boot()
{
Fortify::generateTwoFactorRecoveryCodesUsing(GenerateTwoFactorRecoveryCodes::class);
// ...
}
If missing, the "Regenerate" button will post but no action runs, often silently.
Risk: regenerating invalidates all previous codes immediately. Communicate this to users before triggering.
4. Validate challenge submission
Jetstream posts recovery code attempts to /two-factor-challenge with field recovery_code. The middleware does not auto-prompt for recovery codes; the user must select "Use a recovery code" on the challenge page.
If the session lacks the two_factor_authenticated flag, the route may redirect back to login instead of accepting the code. Check that the user reached the challenge via the normal login flow and not via a direct GET.
Limitation: you cannot test a code without knowing a previously generated one. Codes are hashed with Hash::make() so they cannot be recovered from the database.
Fixes tied to findings
- Feature flag off: set
'confirm' => trueinconfig/fortify.php, clear config cache, and republish the profile components for your stack. - Action not bound: add the
Fortify::generateTwoFactorRecoveryCodesUsingbinding inFortifyServiceProvider::boot(). - Column unreadable: if the column is
textwith incompatible collation, migrate tojsonon a staging copy first. Do not manually editrecovery_codes; you would need to regenerate via the action. - APP_KEY changed: on Laravel 10+,
two_factor_secretandrecovery_codesare encrypted. A changedAPP_KEYmakes decryption fail. Restore the original key or users must re-enable 2FA.
Escalation criteria
Escalate when:
- Multiple users have null
recovery_codesdespitetwo_factor_secretbeing set, suggesting a migration or seeding bug. - Regeneration works for some users but not others with identical config, indicating per-user encryption or data corruption.
- You need to recover access for a locked-out admin and no recovery codes were saved. The safe path is to disable 2FA for that user via a privileged console command after verifying identity, then re-enable 2FA and provide new codes.
Limitations: this guide covers standard Jetstream + Fortify setups. Custom 2FA actions or third-party packages may override the default flow. Always verify changes on a non-production environment first.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.