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

Login challenges

When a first factor verifies but more is needed, the result is a challenge: an opaque token (only its HMAC is stored), the steps that remain and the attempts left. Steps complete in order; verification steps always come before enrolment steps:

POST /users/auth/login
{ "identifier": "[email protected]", "password": "…" }

200 { "status": "challenge", "challenge_token": "…", "expires_at": "…", "attempts_left": 5, "method": "password",
      "completed": [], "remaining": [ { "step": "second_factor", "methods": ["totp", "recovery_code", "passkey"] } ] }

POST /users/auth/challenge/two-factor
{ "challenge_token": "…", "code": "123456" }

200 { "status": "authenticated", "token_type": "Bearer", "access_token": "eyJ…", "refresh_token": "…", "session_id": "0199…" }

Steps

StepMethodsEndpoints
second_factortotp, recovery_code, passkeychallenge/two-factor, challenge/passkey/options + challenge/passkey
passkeypasskeychallenge/passkey/options + challenge/passkey
enrol_two_factortotp_enrolmentchallenge/two-factor/enrol + …/confirm
enrol_passkeypasskey_enrolmentchallenge/passkey/enrol/options + challenge/passkey/enrol

Which steps a login needs

It depends on the guard’s two-factor mode, passkey mode and passkey second-factor rule, on what the account has enrolled, and on the risk reaction:

  • Passkey primary — an enrolled TOTP is asked for only when satisfies_mfa is off; under two_factor.mode = required without TOTP, enrolment is forced when satisfies_mfa is off or required_with_passkey is on.
  • Email primaries (magic link, email code, invitation) skip the two-factor policy when two_factor.after_email_login is false. A password reset is never exempt, and neither is registration (it proves no mailbox).
  • An enrolled TOTP means a second_factor step (TOTP, recovery code, and a passkey when second_factor = allowed).
  • passkeys.second_factor = required (or required_when_enrolled with a passkey) adds a passkey step — or passkey enrolment when the account has none.
  • two_factor.mode = required without TOTP forces enrolment — or accepts the account’s passkey as the second factor when second_factor = allowed and passkey_satisfies_required is on; passkeys.mode = required without a passkey forces passkey enrolment.
  • A risk reaction of require_second_factor prepends a second-factor step, or denies when the account has none. It applies to every login method — after_email_login = false exempts email logins from the two-factor policy, never from a step-up. A passkey login that counts as MFA (satisfies_mfa) already is the step-up; otherwise only TOTP or a recovery code counts, else the login is denied.

Configuration

'challenge' => [
    'ttl' => 300,
    'enrolment_ttl' => 900,
    'max_attempts' => 5,
    'allow_enrolment' => true,
    'enrolment_requires_verified_email' => true,
    'bind' => ['user_agent' => true, 'ip' => false, 'device_header' => true],
    'max_active_per_account' => 3,   // older active challenges are superseded
],

'two_factor' => [
    'mode' => env('AUTHENTICATION_TWO_FACTOR', 'optional'),  // off|optional|required
    'after_email_login' => true,          // magic link / email OTP / invitation also need the 2nd factor
    'required_with_passkey' => false,     // passkey primary still forces TOTP enrolment when mode=required
    'passkey_satisfies_required' => true, // a passkey second factor satisfies mode=required (false ⇒ TOTP also required)
],

'passkeys' => [
    'mode' => env('AUTHENTICATION_PASSKEYS', 'optional'),    // off|optional|required
    'second_factor' => 'allowed',        // off|allowed|required_when_enrolled|required
    'satisfies_mfa' => true,             // a UV passkey login needs no further factor
],

Lifecycle and safety

  • Device binding — a fingerprint of user agent, IP and X-Device-Id per challenge.bind (IP off by default: mobile clients change networks mid-login), compared in constant time.
  • Attempts — a TOTP, recovery or enrolment-confirmation code takes its attempt atomically before it is checked, so overlapping guesses never check more codes than max_attempts allows; a code that verifies gives its attempt back. Passkey assertions count on failure only. At max_attempts the challenge is invalidated and the response carries attempts_left.
  • Supersession — at most max_active_per_account challenges stay active; older ones are superseded.
  • Single use — steps advance with an optimistic version check; finalisation re-checks the account state and its token-version snapshot, so a reset, disable or logout-everywhere in between kills the login.
  • Invalidation — any invalidation with a scope other than none also invalidates the account’s active challenges.

Enrolment inside a challenge

Forced enrolment requires challenge.allow_enrolment and — with enrolment_requires_verified_email — a verified address; otherwise the answer is enrolment_required (403). Starting enrolment again re-issues it without counting an attempt; a wrong confirmation counts one. Confirming TOTP enrolment fires TwoFactorEnabled and ends other sessions, but not the challenge itself.

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.