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

Verifying at login

After the password step, verify the code the user types. TwoFactor::for($user)->attempt() accepts either a TOTP code or a recovery code and returns a VerificationResult:

use RoundlyConsulting\TwoFactor\Exceptions\TwoFactorRateLimitedException;
use RoundlyConsulting\TwoFactor\Facades\TwoFactor;

try {
    $result = TwoFactor::for($user)->attempt($request->string('code')->toString());
} catch (TwoFactorRateLimitedException $e) {
    // too many failed attempts — retry after $e->secondsUntilAvailable seconds
}

if ($result->verified) {
    // accepted — TOTP (replay-safe) or a single-use recovery code
}

Knowing how the challenge was passed

The result tells you whether the code passed, by which method, and how many recovery codes are left:

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
}
PropertyTypeMeaning
verifiedboolThe code passed — TOTP (replay-safe) or a single-use recovery code.
method?TwoFactorMethodTotp or RecoveryCode; null when not verified.
remainingRecoveryCodesintRecovery codes left after this attempt.
replayedboolA valid TOTP code whose timestep was already claimed.

The remaining count after a recovery code is read from the row locked for the spend, so it is correct even when two requests race.

Order of checks

  • A user without a confirmed enrolment fails immediately — a pending enrolment is never a valid second factor.
  • The attempt is counted against the built-in limiter first, in one atomic increment. Past attempts.max, TwoFactorRateLimitedException is thrown before any verification work.
  • The TOTP code is checked across the drift window; a match atomically claims its timestep. An already-claimed timestep is a replay — the result has replayed: true and TwoFactorReplayDetected fires.
  • Otherwise the code is tried as a single-use recovery code — normalized the way people type it — and consumed on a match.
  • A success clears the limiter counter and fires TwoFactorVerified. A failure or a replay stays counted; a genuine failure also fires TwoFactorVerificationFailed.

From the user model

Two trait shorthands run the same attempt(): one returns the result, the other only the bool:

$result = $user->attemptTwoFactorCode($code);  // → VerificationResult (== TwoFactor::for($user)->attempt())
$ok     = $user->verifyTwoFactorCode($code);   // bool (== ->attempt($code)->verified)

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.