Security
Each property below is covered by a test in the package suite.
- Enumeration — guest endpoints answer known, unknown, disabled and unverified accounts identically (status and body) — registration too in the safe modes, in every verification mode with registration.uniform_response; mail goes only to real, active accounts, after the response. A password check always runs, against a dummy hash of the same cost for unknown accounts, and a locked account’s password login answers like a wrong password (same status, body, hash cost and throttle count); a correct password behind any uniform invalid_credentials keeps its throttle attempt and leaves the lockout counter alone, like a wrong one. An email change to a taken address answers and notifies the old address exactly like one to a free address. Only completed logins make a device known — a reset or sign-in link requested from a device does not, nor does a re-authentication made with a (possibly stolen) access token.
- Guard isolation — a token, link, code, invitation, challenge or passkey of one guard never works on another.
- Single use — every claim is a conditional update; challenges advance with an optimistic version check and snapshot the account’s token version.
- Invalidation — tv++ kills every outstanding access token; session families are revoked (one minted under an older tv — a login racing the invalidation — at its first refresh, so no session outlives a reset, a disable or a logout everywhere), and a refresh retires the session’s previous access token, so revoking a session kills every token it minted; pending sign-in, reset, re-authentication and email-change links die with them; the jti denylist is belt-and-braces.
- Throttling — attempts are counted atomically before a password or code is checked — the login, email and re-authentication throttles, email codes and every challenge’s own max_attempts — so a burst of concurrent guesses cannot all pass the limit. The challenge cap is per challenge: with two-factor’s per-account limiter off (two-factor.attempts = null) it is the only bound and each new login opens a fresh budget, so keep the limiter on or throttle the challenge routes yourself (authentication:check warns).
- Risk step-up — require_second_factor applies to every login method, also when after_email_login = false; an account with no factor to step up with is denied. A passkey login that counts as MFA (passkeys.satisfies_mfa) already is the step-up.
- Re-authentication — accounts with a second factor must re-authenticate with it; a password or email-code proof, or a login that skipped the factor, never satisfies a gate for them — also when the factor was enrolled after that proof. reauthentication.methods is checked against the factor that actually matched.
- Secrets at rest — HMAC-SHA-256 with a key derived from APP_KEY (or hash_key); plaintext exists only in the response or the notification.
- Account pre-hijacking — with verification.secure_on_email_login, the magic-link or email-code login that verifies an unverified address removes the password, the two-factor enrolment and the passkeys set before the address was proven and ends every session (AccountClaimed); the claim is a conditional update in one transaction with the clean-up, and a password login racing it gets a pair that is refused.
- Account recovery — last-credential protection, forced enrolment only for verified addresses, email changes verified and announced twice, a reset never bypasses an enrolled second factor and registration never skips required two-factor enrolment.
- Transport — Cache-Control: no-store on every package response, encrypted queued notifications and secrets in URL fragments.
- Stack traces — the Authentication facade uses package-toolkit’s RedactsSensitiveArguments, so the #[SensitiveParameter] arguments of consumeMagicLink() ($token), verifyEmailOtp() ($code) and refresh() ($refreshToken) show as SensitiveParameterValue even in the facade’s own frame, while every other argument stays visible.
Hardened preset
Today’s defaults keep the 1.x/2.x behaviour. Four settings close the gaps they leave — they are the defaults planned for the next major. Set them on a guard one by one, or all at once with GuardPreset::hardened() (see Configuration):
| Key | Today | Hardened | Closes |
|---|---|---|---|
registration.uniform_response | false | true | Registration revealing a taken address while it would sign in at once. A taken address gets the very answer a free one does (accepted or verification_required, same hash cost) and its holder an account-exists notice; registration never signs in. |
verification.mode | optional | required_for_login | Unverified accounts signing in — anyone can register any address. |
verification.secure_on_email_login | false | true | Account pre-hijacking: credentials set before the address was proven survive its owner’s first email login. verify_on_email_login stays true, which it needs. |
lockout.enabled | false | true | Unlimited slow password guessing across IPs beyond the throttles. Trade-off: anyone can lock an account by failing on purpose — the owner is notified and lockout.reset_unlocks lets a password reset lift it. |
The preset sets verification.verify_on_email_login = true too. Everything else is already safe by default — uniform risk denial, reveal_account_state only after a verified first factor, fragment URLs, invitation address checks.
Upgrade notes for the next major
The next major makes the four hardened values the defaults. To keep today’s behaviour after upgrading, set them explicitly on the guard or under defaults:
// Keep today's behaviour after the next major — set on the guard or under defaults
'registration' => ['uniform_response' => false],
'verification' => ['mode' => 'optional', 'secure_on_email_login' => false],
'lockout' => ['enabled' => false],Before switching them on, check what they change for your users: registration no longer signs in (send them to the login after sign-up), unverified accounts must verify before signing in, an email login on an unverified account removes a password set before verification (the user sets one again), and repeated failures lock the account for lockout.duration.
Known limits
- The denylist lives in cache — a flush revives revoked, unexpired access tokens until they expire. The token version covers invalidations; a short access_ttl bounds the rest.
- Forced enrolment — a password-only attacker could enrol their own factor under a required mode; mitigated by the verified-address gate and notifications, and a challenge whose enrolment step went stale (the factor was set up meanwhile) is ended.
- Registration with immediate tokens reveals a taken address — use the enumeration-safe modes or registration.uniform_response.
- Pre-hijacking through a verification link — secure_on_email_login doesn’t run when the address is verified with the link or code mailed after registration: that is the registrant’s own step, and they do know the password. If the address’s owner clicks such a link for an account they didn’t create (the mail says to ignore it), the earlier password stays. Under required_for_login nobody signs in before that click.
- A credential change already in flight — a session that passed authentication just before a claim (or a password reset) can still finish a password change it started; the claim ends that session, so it needs the old password and that exact millisecond window.
- One passkeys relying party for all guards, and one key type for every guard model.
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.