Login methods
Four primary methods, each switchable per guard. At least one must be enabled:
'guards' => [
'users' => [
'model' => App\Models\User::class,
'login' => [
'password' => true,
'magic_link' => true,
'email_otp' => true,
'passkey' => true, // passwordless
'reveal_account_state' => true, // disabled/unverified codes only after a verified first factor
],
'magic_link' => ['ttl' => 900, 'same_device' => false],
'email_otp' => ['ttl' => 600, 'length' => 6, 'max_attempts' => 5],
],
],$guard = Authentication::guard('users');
$context = $guard->contextFrom($request);
// Password
$result = $guard->attempt(new PasswordCredentials('[email protected]', $password), $context);
// Magic link: the request never reveals whether the address exists
$guard->requestMagicLink('[email protected]', $context);
$result = $guard->consumeMagicLink($token, $context);
// Email code
$guard->requestEmailOtp('[email protected]', $context);
$result = $guard->verifyEmailOtp('[email protected]', $code, $context);
// Passwordless passkey (discoverable credentials)
$options = $guard->passkeyLoginOptions($context); // for navigator.credentials.get()
$result = $guard->loginWithPasskey($assertion, $context); // passkeys' AuthenticationResponseDataPassword
The login, login_ip and login_account throttles are checked before any hashing. Unknown or passwordless accounts are checked against a dummy hash of the same cost, so response time does not reveal whether an account exists. A failure hits all three buckets, bumps the opt-in lockout counter and answers invalid_credentials. A hard-locked account answers exactly like a wrong password — a dummy check, the attempt stays counted, invalid_credentials — so a lock confirms neither the password nor that the account exists; locked_until is read again once the password is hashed, so a lock written meanwhile refuses that login the same way, a correct password included. A correct password gets its attempt back only once the login is challenged or completes: one the login then refuses with the uniform invalid_credentials (a disabled or unverified account without reveal_account_state, a uniform risk denial, an unavailable step-up) stays counted like a wrong one, so the throttle never tells the right guess apart.
Magic link
A real, enabled account receives a 64-character link with the secret in the URL fragment; the request always answers 202 sent. On consume, the link’s address must still be the account’s. With magic_link.same_device, the requesting device’s fingerprint is checked before the claim, so a mismatch does not burn the link.
Email code
Same request semantics as the magic link. Verification runs through the three login buckets (a code guess is a login attempt), never reveals attempts_left and binds the code to its address.
Passkey
Options are discoverable — no identifier, no allowCredentials. With passkeys.satisfies_mfa they require user verification, enforced for that ceremony. The ceremony id must come back with the assertion, another guard’s credential is refused, and every failure is a uniform invalid_credentials.
What every login goes through
- Possession side effects — a magic-link or email-code login verifies the address (verification.verify_on_email_login); with verification.secure_on_email_login that first verification also takes the account over from whoever registered the address before its owner — see Account claim below.
- Account state — disabled answers account_disabled (or invalid_credentials with reveal_account_state off); locked answers too_many_attempts on a link, code or passkey login (a password login has already answered invalid_credentials); unverified under required_for_login answers email_not_verified.
- Password rehash.
- Locale fill when the account has none, and new-device detection.
- Risk — the configured reaction can notify, require a second factor or deny, whatever the login method.
- Required steps — a step-up with no factor to step up with is denied. Only past this point are the lockout counter reset and the login bucket cleared, so a uniform refusal before it leaves both exactly as a wrong password would.
- Either a challenge, or the success tail: tokens, last_login_at, activity, LoginSucceeded and TokensIssued.
Account claim
Account pre-hijacking: someone registers an address that isn’t theirs, sets a password and keeps a session — or enrols TOTP or a passkey. When the address’s owner later signs in with a magic link or an email code, that login proves the mailbox, never the password, and by default the earlier credentials stay valid. With verification.secure_on_email_login = true (opt-in, per guard), the email login that verifies an unverified address removes, in one transaction:
- the password and password_changed_at — null, or an empty string where the column is NOT NULL; both mean no password, and no hash ever matches;
- the two-factor enrolment, confirmed or pending;
- every passkey, when the guard’s passkeys.mode isn’t off;
- every session and refresh token, pending challenge and credential one-time secret (reset links, sign-in links and codes, pending email changes) — the security invalidation, always all. The current login’s pair is issued afterwards, so the owner stays signed in.
Then AccountClaimed fires. The owner adds a password or factors again from their session — setting a password needs a recent re-authentication by default, and the fresh email login counts:
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Auth\Events\AccountClaimed;
Event::listen(function (AccountClaimed $event): void {
$event->method; // the email login that proved the address (LoginMethod)
$event->passwordRemoved; // bool
$event->twoFactorRemoved; // bool
$event->passkeysRemoved; // int
$event->sessionsRevoked; // int
});It is race-safe: the verification is claimed with a conditional update in the same transaction, so of two racing email logins only one secures the account, and what to remove is judged on the row re-read inside that transaction. A password login that loaded the account before the claim mints its pair under the old token version: its access token is refused and its refresh family revoked at the first refresh.
It doesn’t run on a verification link or code (the registrant’s own step after signing up with a password), a password login, a password reset (which replaces the password and ends every session anyway), an invitation (it only ever creates a new account) or an already verified address. With verify_on_email_login = false an email login leaves the address unverified and nothing is removed — authentication:check warns.
Authentication method references
Access tokens carry amr (RFC 8176). A completed challenge adds its factors and mfa:
| Primary method | amr |
|---|---|
| Password | pwd |
| Magic link | |
| Email code | otp |
| Passkey | hwk, user (+ mfa when user verification was required) |
| Invitation | email (none for an unverified chosen address) |
| Password reset | |
| Registration | pwd or none |
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.