NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Each enrolment generates recovery_codes.count codes (default 8) in the form XXXXX-XXXXX — two five-character segments from A–Z and 0–9, with the letter O left out so codes stay unambiguous on paper. They are returned once, in $setup->recoveryCodes.

Using a recovery code

There is no separate call: attempt() falls back to recovery codes when the TOTP check fails. A matching code is burned, RecoveryCodeConsumed fires with the remaining count, and the result reports TwoFactorMethod::RecoveryCode:

use RoundlyConsulting\TwoFactor\Enums\TwoFactorMethod;
use RoundlyConsulting\TwoFactor\Facades\TwoFactor;

$result = TwoFactor::for($user)->attempt($request->string('code')->toString());

$result->verified;               // bool — TOTP (replay-safe) or a single-use recovery code
$result->method;                 // TwoFactorMethod::Totp | ::RecoveryCode | null on failure
$result->remainingRecoveryCodes; // int, after this attempt — "1 recovery code left"
$result->replayed;               // true when a valid code's timestep was already used

if ($result->method === TwoFactorMethod::RecoveryCode) {
    // e.g. notify the user, add 'recovery_code' to an amr claim
}

Consumption re-reads the row under a transaction lock before removing the matched code, so a code phished once cannot be raced through twice. Recovery codes are accepted regardless of the TOTP replay guard.

Typed the way people type

Recovery codes are matched the way people type them: case, surrounding whitespace and the dash (or a space in its place) don’t matter, and a typed letter O reads as the zero it was mistaken for — codes never contain an O. Every spelling maps onto one issued code, and each code still matches only once:

// The user was issued YWNLY-0J5BK and types it loosely:
$result = TwoFactor::for($user)->attempt('ywnly 0j5bk');

$result->verified; // true — the code is spent
$result->method;   // TwoFactorMethod::RecoveryCode

// 'YWNLY0J5BK', ' ywnly-0j5bk ' or 'YWNLY-OJ5BK' (a letter O for the zero)
// would have matched the same code — once.

An imported code of any other shape is only trimmed and must otherwise match exactly.

Regenerating

use RoundlyConsulting\TwoFactor\Facades\TwoFactor;

$codes = TwoFactor::for($user)->recoveryCodes()->regenerate(); // new plaintext set — show once
$left  = TwoFactor::for($user)->recoveryCodes()->remaining();  // drive a "regenerate?" prompt

// The same two calls on the user model:
$codes = $user->regenerateTwoFactorRecoveryCodes();
$left  = $user->twoFactorRecoveryCodesRemaining();

Regenerating replaces the whole set, fires RecoveryCodesRegenerated and returns the new plaintext codes — the only copy, so show them once. remaining() reads the stored count, so you can prompt the user to regenerate when it runs low.

Hashed or encrypted storage

The default hashed mode stores one-way Hash::make() hashes — a database dump plus a leaked APP_KEY never yields live recovery codes. The opt-in encrypted mode keeps the plaintext codes encrypted at rest so you can display them again after enrolment, at the cost of being reversible with APP_KEY:

'recovery_codes' => [
    'count' => 8,
    'storage' => 'encrypted', // 'hashed' (default) | 'encrypted'
],

Switching modes invalidates existing stored codes — only change recovery_codes.storage on a fresh enrolment base, and regenerate codes for enrolled users if you switch.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.