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
}| Property | Type | Meaning |
|---|---|---|
verified | bool | The code passed — TOTP (replay-safe) or a single-use recovery code. |
method | ?TwoFactorMethod | Totp or RecoveryCode; null when not verified. |
remainingRecoveryCodes | int | Recovery codes left after this attempt. |
replayed | bool | A 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 cryptoBy 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.