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

Two-factor & passkeys

Setting up factors during login is covered in Login challenges. This page covers a signed-in account managing its own factors. The ceremonies belong to two-factor-for-laravel and passkeys-for-laravel; this package adds guard modes, re-authentication, invalidation, events and notifications.

Two-factor (TOTP + recovery codes)

Available when the guard’s two_factor.mode is not off:

StepHTTPResponse
StatusGET two-factorenabled, pending, recovery_codes_remaining, mode
StartPOST two-factorsecret, provisioning_uri, qr_svg, recovery_codes, issuer
ConfirmPOST two-factor/confirmstatus enabled + tokens
Regenerate recovery codesPOST two-factor/recovery-codesrecovery_codes + tokens
Disable (mode optional only)DELETE two-factorstatus disabled + tokens

From PHP, the twoFactor() sub-context runs the same steps — pass the caller’s $current token to keep its device and get the re-issued pair:

use RoundlyConsulting\Auth\Facades\Authentication;

$twoFactor = Authentication::guard('users')->twoFactor();

$status = $twoFactor->status($user);      // enabled, pending, recoveryCodesRemaining, mode
$setup  = $twoFactor->start($user);       // show $setup->qrSvg / ->secret, store ->recoveryCodes
$tokens = $twoFactor->confirm($user, $code, $current, $context);

$codes  = $twoFactor->regenerateRecoveryCodes($user, $current, $context);   // codes, tokens
$tokens = $twoFactor->disable($user, $current, $context);
  • Starting while already enabled answers two_factor_already_enabled (409); qr_svg is null when two_factor.qr.enabled is off or rendering failed.
  • A wrong or stale confirmation code answers invalid_code (422); disabling under required answers two_factor_required (409).
  • Confirm, regenerate and disable apply invalidation.two_factor_changed (default others): every other session ends and the caller gets a fresh pair — swap immediately.
  • Events TwoFactorEnabled, TwoFactorDisabled and RecoveryCodesRegenerated; using a recovery code at login fires RecoveryCodeUsed and mails the remaining count.
  • An admin disabling it passes themselves as actor: and no $current — Authentication::twoFactor()->disable($user, actor: $admin). The owner’s two_factor_disabled mail then says an administrator did it, and TwoFactorDisabled carries the admin as actor. Another actor together with $current throws InvalidArgumentException before anything is written.

Passkeys

Available when the guard’s passkeys.mode is not off:

StepHTTPResponse
ListGET passkeysPasskeys, newest first
OptionsPOST passkeys/optionsceremonyId + publicKey
RegisterPOST passkeys201 passkey + tokens
RenamePATCH passkeys/{passkey}The passkey
RemoveDELETE passkeys/{passkey}status removed + tokens

From PHP, through the passkeys() sub-context — rename() and remove() take a Passkey or its id, and another account’s passkey is PasskeyNotFound:

use RoundlyConsulting\Auth\Facades\Authentication;

$passkeys = Authentication::guard('users')->passkeys();

$options = $passkeys->registrationOptions($user);                          // ceremonyId + publicKey
$added   = $passkeys->register($user, $response, 'MacBook', $current, $context);   // passkey, tokens

$passkeys->all($user);                                                     // newest first
$passkeys->rename($user, $passkeyId, 'Work laptop');                       // a Passkey or its id
$tokens = $passkeys->remove($user, $passkeyId, $current, $context);
  • A failed attestation answers passkey_registration_failed (422); names are sanitised.
  • Removing the last passkey is refused with last_credential (409) when the account would have no other way in, or the guard requires passkeys. The count and the removal run under a lock on the account row, so of two concurrent removes one succeeds and the other gets last_credential — never both.
  • Register and remove apply invalidation.passkey_changed (default none, so tokens is null) and fire PasskeyAdded / PasskeyRemoved.
  • An admin removing a passkey passes themselves as actor: (and no $current): passkey_removed then says an administrator removed it, and PasskeyRemoved carries the actor. Same rules as for two-factor.

How the modes combine at login

SettingEffect
two_factor.mode = requiredAccounts without TOTP must enrol during login (or satisfy it with a passkey when passkey_satisfies_required).
passkeys.mode = requiredAccounts without a passkey must enrol one during login — any login method, also an email login that two_factor.after_email_login = false exempts from the second factor.
passkeys.second_factorWhether a passkey may (allowed) or must (required_when_enrolled, required) be the second step after a password or email login.
passkeys.satisfies_mfaA user-verified passkey login counts as multi-factor on its own — also under a risk step-up; with false a step-up demands TOTP.

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.