joe-404/laravel-auth
Config-driven, drop-in auth for Laravel 12/13: JSON API for registration with OTP/magic-link verification, login, refresh tokens, password reset, Google OAuth, multi-session/device fingerprinting, long-lived API tokens, account status workflows, and referrals.
Hardening + privacy follow-up to v2.7.2. Safe, non-breaking upgrade — no migrations, defaults preserve today's behavior. See UPGRADING.md.
consumed_at write in TwoFactorChallengeService::verify() is now a
conditional WHERE consumed_at IS NULL update. Two concurrent verifies with
valid codes for two different enrolled factors can never both produce a
login success — the row is single-use even under concurrency.AdminGate (matching
v2.7.1's admin status / api-token groups). The remaining hard-coded role:
on GET/PATCH /auth/admin/referrals was replaced with
auth.admin-gate:referral_code. Default resolves to super-admin|admin —
behavior unchanged for existing hosts.AccountDeletionService no longer stores $user->toArray() raw in
deleted_accounts.snapshot — the password hash, remember_token, and any
configured custom fields are removed before persistence. Closes a privacy
gap where a host User without $hidden could leave credential material in
the permanent deletion audit row.api_tokens.require_step_up_for_revoke — gate DELETE /auth/api-tokens/ {id} behind a fresh step-up. Mirrors the create-side flag. The high
security profile now flips this on automatically.referral_code.admin_middleware / referral_code.admin_ability —
configurable admin gate for the referral admin routes, matching the
account.status / api_tokens pattern.account.deletion.snapshot_strip_fields — explicit per-deletion strip
list; defaults to null (falls back to response.hidden_user_fields).Concurrency-hardening follow-up to v2.7.1. Fully safe upgrade — no migrations, no new config keys, no behavior changes for sequential use. See UPGRADING.md.
BackupCodeService::consume()
switched from read-then-write to a conditional WHERE used_at IS NULL
update; two concurrent verifies of the same code can never both succeed.
(Higher-stakes than regular OTPs — single-use recovery, full 2FA bypass on
replay.)PhoneVerificationService::verifyCode() now uses the same conditional-
update pattern on consumed_at. Failed-attempt incrementing was already
atomic and is unchanged.last_totp_timestep write in
TwoFactorService::verifyTotp() is now a conditional
WHERE last_totp_timestep IS NULL OR < $step update — only the request
that strictly advances the step wins under concurrency. Also enforces
monotonic step advancement (the column can never regress).api_tokens.admin_require_step_up.
In v2.7.1 only POST was gated; in v2.7.2 the SAME flag also gates
mutation and revocation, closing the asymmetry where a hijacked admin
session could sidestep the create gate by editing or deleting an existing
token.Audit-driven follow-up to v2.7.0. Safe, non-breaking upgrade — every behavior change is behind a config flag defaulting to today's behavior. See UPGRADING.md.
Note on the v2.7.0 release: the published
v2.7.0tag was placed on the v2.6.1 commit before the v2.7 work was merged, socomposer require joe-404/laravel-auth:^2.7.0resolves to the v2.6.1 code. Upgrading tov2.7.1delivers the real v2.7 security pass plus the v2.7.1 items below in one step.
UPDATE … WHERE used_at IS NULL so two
concurrent requests with the same valid code can never both succeed
(security-review #1).created_by_* columns — user-
issued tokens self-attribute, admin-issued (unowned) tokens carry the
admin's id (security-review #8 audit half).password_reset.auto_login — set false to force a fresh login after a
reset; revokes every session and issues no token (security-review #4).api_tokens.admin_require_step_up — admin token creation can require a
fresh sudo / 2FA step-up, mirroring the user-side flag.AdminGate middleware replaces the hard-coded role: on package admin
route groups. Configurable via account.status.admin_middleware and
api_tokens.admin_middleware — accepts any pipe-separated list of roles
and/or Spatie permissions (security-review #7).response.hidden_user_fields — the always-stripped serialization list
is now config, so hosts can add custom sensitive columns without losing
the defensive net.security.profile preset (relaxed | balanced | high) — fills in
safe defaults at boot, but only for keys whose env var is unset, so any
explicit .env value still wins. high flips on every hardening flag the
library exposes.composer audit as a release-gate check.Second security-hardening pass (audit-driven). Safe, non-breaking upgrade: every behavior change is behind a config flag that defaults to the current behavior. See UPGRADING.md for the flag-by-flag guide and the recommended production profile.
auth_otp_codes.type was
an enum that never included the 2FA purposes (two_factor_email,
two_factor_email_enroll), so storing an email-2FA code was rejected on
strict MySQL / PostgreSQL / SQLite — the method was effectively broken. The
column is widened to string(40).safeUserArray() net (strips password
/ remember_token) as every other endpoint, instead of raw toArray().Require2FA cast the user key
to int while the writer used the raw key, silently breaking step-up for
non-integer primary keys; all step-up keys now use the raw key.POST /auth/2fa/challenge/switch is rate-limited (it delivers a code),
and POST /auth/password/confirm is throttled per-user so a hijacked
session can't brute-force the account password for a sudo window.account_status, status_expires_at, phone_verified_at,
two_factor_required, …) — the privileged-field denylist was extended.APP_KEY is unset — it is the pepper for OTP /
backup-code hashing and the key for 2FA-secret encryption.two_factor.required is now enforced on the package's own authenticated
routes via the new auth.require-2fa-enrolled middleware, returning a
must_enroll_2fa envelope while leaving the enroll / login / logout / me /
password/confirm endpoints reachable. Login also surfaces a
must_enroll_2fa hint.api_tokens.strict_abilities): a normal user
may only self-grant abilities from api_tokens.grantable_abilities and never
the * wildcard (reserved for admin-issued tokens). New api_tokens.mode
(customer_auth | third_party) and api_tokens.max_ttl_days cap.api_tokens.require_step_up).account.status.admin_actions.enforce_role_hierarchy):
an admin may only change a strictly lower-ranked account — not a peer, a
higher role, or themselves — and deleted can no longer be set via the
status endpoint. Configurable role_ranks, allow_self_action,
allow_equal_rank.social.enforce_state):
a one-time server-managed state is verified on the mobile/SPA callback,
closing login-CSRF / authorization-code injection on the stateless path.security.lockout.scope: email | ip |
email_and_ip) plus optional backoff, mitigating the known-email targeted
lockout DoS.trusted_devices.registration_device_level).DB_CONNECTION=mysql; a new
GitHub Actions workflow runs the full suite on SQLite (PHP 8.2 / 8.3) and on
strict MySQL.Security hardening pass over the v2.6.0 surface. Closes several 2FA-bypass paths and tightens defaults. Mostly backward-compatible; see the two behavior-change notes below.
challenge_token (and must complete
/auth/2fa/challenge) exactly like password login, instead of being issued
a token directly.assertCanLogin() checks as password login (suspended /
disabled are rejected; self-deactivated accounts auto-reactivate), not just
the is_active flag.challenge_token; the real token is issued only after the
second factor is verified.completion_token / reset_token / access+refresh tokens in the URL
fragment (#…), which is never sent to servers, logs, or Referer.auth.active is now applied to the default authenticated + admin route
groups, so a mid-session suspension/disable takes effect on the very next
request instead of at token expiry.auth.step-up middleware) on destructive 2FA
actions (remove a method, regenerate backup codes) and phone change. Mode is
config-driven via two_factor.step_up_mode (password_confirm default |
two_factor). Admin status-change step-up is available too but opt-in
via account.status.require_step_up (default off, to avoid breaking
existing admin clients).password / remember_token from the
serialized user even if the host model omits them from $hidden.password.min_length raised 8 → 15 (NIST SP 800-63B-4
single-factor posture; composition rules remain off). Only affects new
passwords; existing hashes are untouched. Override with AUTH_PASSWORD_MIN
(hard floor 8, enforced at boot).UserLoggedIn semantics unchanged from 2.6.0 — still fires once at
credential success.two_factor.step_up_mode config + auth.step-up middleware.account.status.require_step_up config (opt-in admin step-up).password.min_length must be ≥ 8.['*'] abilities (a user's own
session acts as the user; ability scoping is for the separate API-token
feature). Documented, not changed.users for
portability across host PK types (UUID, custom table names, engines).composer update joe-404/laravel-auth # no new migrations in 2.6.1
If you relied on the old 8-char password default, set AUTH_PASSWORD_MIN=8.
SPA clients must read post-redirect tokens from window.location.hash
(fragment) instead of the query string.
Adds phone capture + verification, full Two-Factor Authentication (TOTP /
Email / SMS), backup codes, and a trusted-device system with time-based
trust levels. New Require2FA middleware provides GitHub-style step-up
authentication for sensitive endpoints. Additive only — existing users
without 2FA enrolled see no flow changes.
phone, phone_verified_at, two_factor_required columns to the
users table.PhoneDriverContract for
custom drivers. Per-channel (sms/voice/whatsapp) provider selection with
optional fallback driver. Default log driver writes codes to the Laravel
log for safe local development.pragmarx/google2fa. QR codes
generated server-side via bacon/bacon-qr-code.{ requires_2fa: true, challenge_token, available_methods }
instead of a Sanctum token. The real token is only issued after
POST /auth/2fa/challenge succeeds. Mirrors GitHub/Stripe/Auth0.high.trust_device: true flag at challenge.low (15d) → medium (60d) → high (90d).time, time_consistent, time_admin).Require2FA middleware (auth.2fa) for sensitive endpoints. Three
fallback behaviors when the user has no 2FA enrolled: block,
force_enroll, password_confirm (sudo mode, default).POST /auth/password/confirm issues a 15-minute
step-up window that the Require2FA middleware accepts in lieu of a 2FA
challenge.Request::authContext() macro returning a read-only snapshot:
{ 2fa_enabled, 2fa_verified, trust_level, phone_verified, sudo_active }.POST /auth/phone/send-otp, POST /auth/phone/verifyGET /auth/2fa/methods, POST /auth/2fa/enroll/{method}/{start|verify},
DELETE /auth/2fa/methods/{id}, POST /auth/2fa/methods/{id}/defaultGET /auth/2fa/backup-codes, POST /auth/2fa/backup-codes/regeneratePOST /auth/2fa/challenge, POST /auth/2fa/challenge/switch,
POST /auth/2fa/challenge/resendPOST /auth/password/confirmGET /auth/trusted-devices, DELETE /auth/trusted-devices,
DELETE /auth/trusted-devices/{id}auth:install --upgrade flag for migrating existing installations from
v2.5.x. Runs only the new v2.6 migrations and prints a changelog summary.social.profile_completion.enabled makes a
brand-new OAuth (Google) user complete the host's required registration
fields via POST /auth/social/complete before the account is created,
enforcing the same registration.extra_fields_rules + phone rules as the
email flow. No user row is created until completion. New config keys
AUTH_SOCIAL_PROFILE_COMPLETION / AUTH_SOCIAL_PROFILE_COMPLETION_TTL.UserLoggedIn still fires at credential success — even when 2FA is required
and no token is issued yet — so existing listeners (audit, "user logged in"
notifications) continue to fire on the credential leg. The new
TwoFactorChallengeIssued event fires at the same boundary when a challenge
is created, and TwoFactorVerified fires once the user completes the
challenge.trusted_device_token at trust time (returned in the registration response
and in /auth/2fa/challenge responses when trust_device=true). The
client must echo it back as X-Trusted-Device-Token on subsequent logins
for the bypass to apply. Fingerprint alone — which is client-supplied —
no longer grants MFA bypass.bypass_2fa_min_level raised from medium to high so the
bypass requires the strongest trust signal (registration device, 90 days
of usage, or admin grant). Hosts that intentionally want the looser
v2.6.0-rc semantics can override with AUTH_TRUST_BYPASS_MIN=medium.pragmarx/google2fa: ^8.0bacon/bacon-qr-code: ^3.0All v2.6 migrations are stamped 2025_v260_*. Existing installations:
composer update joe-404/laravel-auth
php artisan auth:install --upgrade
Then add the three new columns to your User model's $fillable:
protected $fillable = [/* …existing… */, 'phone', 'phone_verified_at', 'two_factor_required'];
See the "Upgrading to v2.6.0 from v2.5.x" section in docs/upgrading.md for the full guide.
Security and correctness pass on the refresh-token flow, plus a handful of hardening fixes around device fingerprinting, error responses, and config validation. No breaking changes, no migrations.
TokenService::refresh() used
to mint new tokens without re-checking whether the user was still allowed
to authenticate — a suspended, disabled, or soft-deleted user could keep
rotating tokens for the lifetime of the refresh window. The flow now
re-runs AccountStatusService::assertCanLogin(), hasVerifiedEmail() (if
enabled), and trashed() before issuing the new pair.consumed check and only one would get
through — the other returned a generic "Invalid refresh token" without
triggering family revocation. The row is now lockForUpdate-selected
inside the transaction; any presentation of a consumed token revokes the
whole family, matching RFC 6749 §10.4 strict rotation.AuthService::refreshToken()
rotated the Sanctum token but left auth_sessions_extended.sanctum_token_id
pointing at the now-deleted old token — so /auth/sessions, session
revocation, and last-active tracking silently broke after the first
refresh. The session row is now re-pointed at the new token (or created
if missing).isAuthRoute() honors the configured route prefix. The exception
renderer used to hardcode auth/, so hosts mounting the package under a
custom prefix (api/v1/auth, etc.) lost the JSON envelope around
ValidationException and AuthenticationException. It now reads
auth_system.routes.prefix; for root-mounted setups it falls back to
matching named routes starting with auth..magic_link_target=frontend
but frontend_verify_url / frontend_reset_url was empty or malformed,
emails went out with broken links (?token=... with no host). The package
now throws the new AuthConfigurationException at link-generation time.ApiTokenAuth no longer leaks internal exception messages. Unknown
exceptions during token validation used to be returned verbatim in the
response body — exposing SQL fragments, file paths, or stack-trace hints
to the caller. Known AuthException instances still pass through with
their safe message; unknown errors are logged and replaced with a
generic "Invalid API token.".X-Browser-Fingerprint value used to be accepted verbatim (truncated to
191 chars). It must now be a hex digest within [min, max] characters
(defaults 32 and 128), otherwise it is treated as absent — preventing
arbitrary binary blobs, control characters, or oversized payloads from
being stored in the column. The fingerprint is still advisory and
must not be treated as proof of device identity.http://
on the synchronous request path, adding up to 3s of latency per login.
It is now dispatched as the new BackfillSessionLocation job, which
fills country and city on the session row after creation; the
default endpoint is https://ip-api.com/json/{ip} and is overridable.use Mockery; in tests/Feature/Auth/SocialAuthTest.php
— PHP was emitting a noisy "use statement … has no effect" warning on
every test run.AuthConfigurationException — thrown for programmer-facing
configuration errors (currently: missing frontend magic-link URL).
Subclass of AuthException, default error key auth_misconfigured.BackfillSessionLocation job — queues the GeoIP lookup off the
auth request path. Dispatched by SessionService::create() only when
auth_system.device.resolve_location=true, the IP is public, and the
session row was not pre-populated by a host-app resolver.All have safe defaults — nothing to change unless you want to override:
'verification' => [
// When true (default), TokenService::refresh refuses to mint new
// tokens for an unverified user. Set false to keep the legacy
// behavior where verification is only enforced at login.
'required_for_refresh' => true,
],
'referral_code' => [
'browser_fingerprint_min_length' => 32,
'browser_fingerprint_max_length' => 128,
],
'device' => [
'location_endpoint' => 'https://ip-api.com/json/{ip}',
'location_queue' => 'default',
],
Permanent device history, browser/mobile fingerprinting, and a full referral code system with config-driven anti-abuse detection.
auth_user_devices table. Records survive logout and session revocation,
giving users a full audit trail of every device that has ever accessed their
account.
GET /auth/devices — list every historical device with first/last seen timestamps.DELETE /auth/devices/{id} — forget a device and revoke any active sessions
whose fingerprint_hash matches that device record.DeviceService reads a
fingerprint_hash from the X-Browser-Fingerprint header (browser/SPA) or
from device_id inside the X-Device-Info header (mobile). The hash is
stored on both auth_sessions_extended (new fingerprint_hash column) and
auth_user_devices. A device_signature is derived by priority: fingerprint
hash → device code SHA-256 → browser+OS+platform SHA-256, which de-duplicates
records across reinstalls and browser-clears on the same physical device.ReferralCodeGeneratorContract).POST /auth/referrals/redeem — submit a referral code after registration.
Must be redeemed within AUTH_REFERRAL_REDEEM_WINDOW minutes.GET /auth/referrals — list the authenticated user's outgoing referrals
and their statuses.GET /auth/referrals/stats — aggregate counts per status.GET /auth/admin/referrals — paginated list of all referrals, filterable
by ?status=.PATCH /auth/admin/referrals/{id} — admin override of referral status and
note. Transitioning to valid with no prior redeemed_at triggers the
reward handler automatically.block,
flag, or ignore: on_same_ip, on_same_device, on_same_ip_and_device.AUTH_REFERRAL_ALLOWED_CLIENTS accepts web,
mobile, or both. A request from a disallowed client type fails silently
(200 response, nothing persisted).ReferralRewardHandlerContract::handle(Referral $referral): void and set
the FQCN in config. If the handler throws, the referral reverts to pending
for retry.ReferralCreated, ReferralRedeemed,
SuspiciousReferralDetected (carries a $reason string).referral_code_not_found, referral_self_referral,
referral_already_redeemed, referral_window_expired,
referral_blocked_same_device, referral_blocked_same_ip,
referral_blocked, referral_status_invalid, referral_not_found,
device_not_found.referral_redeemed, referrals_retrieved,
referral_stats_retrieved, referral_status_updated, devices_retrieved,
device_forgotten.docs/referral-codes.md — 15-section guide covering
all referral flows, anti-abuse scenarios, browser and mobile fingerprint
integration, reward handler examples, and admin override workflow.| File | Creates / Alters |
|---|---|
2026_05_20_000001_create_referrals_table |
referrals table |
2026_05_20_000002_add_fingerprint_hash_to_auth_sessions_extended |
fingerprint_hash column on auth_sessions_extended |
2026_05_20_000003_create_auth_user_devices_table |
auth_user_devices table |
POST /auth/session/destroy-orphan is
an unauthenticated endpoint for SPAs to call when /auth/me returns 401 but a
stale session cookie is still present (e.g. after a manual database wipe or
violated lifecycle). Forces the cookie to expire without requiring a valid token.POST /auth/email/resend-verification returned an incorrect response body
when the user's existing OTP had already expired. Now correctly returns the
verification_resent message in all code paths.EmailVerified event. Fired after email verification completes at the
end of the registration flow. Listeners can use this event to trigger
post-verification workflows (welcome emails, onboarding jobs, etc.).docs/ were rewritten
from scratch with full detail: installation walkthrough, configuration
reference for every key, customization guide for all six contracts, events
reference, localization guide, account status and deletion guides, and
upgrading notes. docs/AI_Context.md added as a full repo snapshot for
AI assistants.docs/ excluded from Composer archive. The archive.exclude block in
composer.json now excludes docs/, tests/, and Postman collections so
production installs do not include documentation files.deleted_accounts table migration
declared deleted_at and scheduled_purge_at without a default value, which
MySQL strict mode rejects with SQLSTATE[22007]. Both columns are now
->nullable().AUTH_ROUTES_PREFIX=api/v1/auth in .env or routes.prefix in
config/auth_system.php. Previously hardcoded to auth.AUTH_ROUTES_REGISTER=false to disable
automatic route mounting and include the route file manually inside your own
Route::group(). Useful for host apps that wrap all routes in a versioned
API group.Git tag:
v2.4(tagged without the.0patch suffix).
Account lifecycle: configurable status workflow, timed bans, self-service deactivation, soft-delete with grace-period auto-restore, and a full admin audit log.
active, suspended,
disabled, deactivated, deleted; extensible via config). Login is
rejected when the status is in account.status.login_blocked. The new
auth.active middleware enforces the status on every authenticated request
so a mid-session ban takes effect immediately.GET|POST /auth/admin/users/{id}/status,
gated by the role(s) in account.status.admin_ability. Status changes
optionally revoke all of the user's Sanctum tokens and sessions.expires_at (ISO 8601)
or duration_minutes. Unban fires via two mechanisms: lazy revert on every
status read, and a scheduled sweep job every auto_unban.sweep_minutes
minutes (default 5).POST /auth/account/deactivate. Auto-reactivates on next login.DELETE /auth/account soft-deletes
with a configurable grace window (default 30 days). Login within the window
auto-restores the account. A purge worker nulls unique columns (auto-discovered
via Schema::getIndexes()) and optionally hard-deletes the row after grace.account_status_logs. Admins can add free-form notes. History endpoint with
pagination and filters at
GET /auth/admin/users/{id}/status/history and
POST /auth/admin/users/{id}/notes.HasAccountStatus trait — convenience methods isActive(),
isSuspended(), isDisabled(), isDeactivated(), isDeleted() on the
User model (optional).AccountStatusChanged, AccountDeleted, AccountRestored,
AccountPurged.AccountDeletedNotification, AccountRestoredNotification,
AccountPurgedNotification, AccountStatusChangedNotification,
AccountDeactivatedNotification, AccountReactivatedNotification. All have
publishable Blade views and FQCN config overrides.account_disabled, account_suspended,
account_deletion_disabled, account_deactivation_disabled,
account_status_invalid, account_password_mismatch, account_deleted,
account_restored, account_status_updated, account_deactivated,
account_reactivated.docs/account-status.md, docs/account-deletion.md.SoftDeletes, enabling auto-restore on credential match within the
grace window.InstallCommand next-steps output now recommends adding the SoftDeletes
trait to the host User model.add_account_status_to_users_table — adds account_status,
status_changed_at, status_reason, status_expires_at, deleted_at.create_deleted_accounts_table — stores user snapshots during the grace
period.create_account_status_logs_table — audit trail for status transitions.POST /auth/email/resend-verification did not create a new OTP record when
the user's existing OTP had already expired, causing the resent email to
contain an invalid code. The controller now forces a fresh OTP before sending.InstallCommand rewrite. php artisan auth:install now runs steps in
the correct dependency order, prints clear error messages when a required
package is missing instead of throwing a cryptic exception, and is safe to
re-run on an already-installed app.AuthRolesSeeder pre-flight. The seeder now checks for the roles table
before running and prints a helpful hint (run migrations first) instead of
crashing with a raw SQL error.AuthSessionExtended.docs/installation.md added.Customisation and localization pass. Every user-facing string the package returns flows through Laravel's translation system. Three opt-in registration customisation features added.
config('auth_system.messages.<key>') / config('auth_system.errors.<key>') — static per-key override.trans('auth_system::<file>.<key>') — per-locale translation file, respects app()->getLocale().php artisan vendor:publish --tag=auth-lang.config('auth_system.errors') block — 26 keys for static, locale-independent
error message overrides.registration.extra_fields_messages —
standard Laravel field.rule => message map for extra_fields_rules without
requiring a custom FormRequest subclass.registration.extra_fields_transformers — maps
a target field name to a class implementing ExtraFieldTransformerContract.
Runs post-validation, pre-persist. Useful for derivation
(username_normalized = strtolower(username)) without writing a controller.referral_code config block. When
auth_system.referral_code.enabled=true, the package generates a unique
referral code per new user during finalizeRegistration() and writes it to
the configured column (default referral_code). Swappable generator via
ReferralCodeGeneratorContract.AuthException carries errorKey + replacements. Exception subtypes
now expose errorKey() and errorReplacements() for the translation pipeline.
Placeholder syntax: standard Laravel :name (e.g. :provider, :seconds).AuthException and all subtype constructors changed:
// Before (v2.1.x)
new AuthException(string $message, int $code = 0, ?Throwable $previous = null)
// After (v2.3.0)
new AuthException(string $message, ?string $errorKey = null, array $replacements = [], ?Throwable $previous = null)
Only affects code that instantiates package exceptions directly. Catching
them is unaffected — $e->getMessage() still works.
Customisation pass. Three fully-additive, opt-in features so host apps can configure 99% of registration needs without writing a custom controller.
referral_code config block. When
auth_system.referral_code.enabled=true, the package generates a unique
referral code per new user during finalizeRegistration() and writes it to
the configured column (default referral_code). The generator is
swappable via auth_system.referral_code.generator (FQCN of a class
implementing the new ReferralCodeGeneratorContract).messages config block. Every hardcoded
English string the controllers return can now be overridden per-key (set
any value to null to keep the built-in default). Useful for
localisation, rebranding, or matching your app's tone of voice. Backed by
a new Http\Concerns\ResolvesMessages trait.auth_system.registration.extra_fields_messages config — standard
Laravel field.rule → message map. Lets host apps customise validation
error wording for extra_fields_rules without reaching for
request_class.auth_system.registration.extra_fields_transformers config — maps a
target field name to a class implementing the new
ExtraFieldTransformerContract. Runs after validation, before the field
is persisted. Useful for derivation (username_normalized = strtolower(username))
and normalisation without writing a controller.ApiTokenAuth middleware did not reject tokens that had been revoked in
auth_api_tokens when the underlying Sanctum token still existed. The
middleware now checks the auth_api_tokens revocation status directly before
allowing the request through.GET /auth/register/verify-magic/{token} was registered with the wrong HTTP
method and returned 405 Method Not Allowed. Fixed.PasswordResetController did not correctly handle a signed URL that had
already been consumed. Now returns a clean 422 instead of a 500.EmailVerificationController::resend() did not return a response when the
user was already verified. Now returns 200 with the verification_resent
message.Security hardening pass. Several breaking changes — review carefully before upgrading.
3-step registration. Passwords are no longer accepted in
POST /auth/register and are no longer cached before email verification.
This eliminates the pre-account takeover attack vector present in v1.x.
Old flow (v1.x):
POST /auth/register { email, password } → send OTP/magic
POST /auth/register/verify-otp { email, otp } → create user
New flow (v2.0):
POST /auth/register { email }
POST /auth/register/verify-otp { email, otp } → completion_token
POST /auth/register/complete { completion_token, password }
Refresh tokens moved to auth_refresh_tokens. Atomic rotation with
one-time use. Existing v1.x refresh tokens are invalid — users must log in
again. Run php artisan migrate.
OTP codes stored as SHA-256 hashes. Existing plaintext OTP records from
v1.x will not match any hash lookup. Clear auth_otp_codes before upgrading.
EmailVerified event — sanctumToken parameter removed.
Remove any $event->sanctumToken usage from your listeners.
SocialAuthService::redirectUrl now requires Request $request as a
second argument.
SocialAuthService::handleCallback now returns an array with a status
key ('logged_in' or 'requires_link_confirmation'). Check the status
key before reading user / token.
Social account auto-linking by email removed. Matching email alone is
insufficient proof of ownership. The callback now returns 202 +
requires_link_confirmation and emails a signed confirmation link. Handle
the new GET /auth/social/{provider}/link/confirm/{token} in your frontend.
Magic-link endpoints now 302-redirect to the configured
frontend_verify_url / frontend_reset_url (with token in the query string)
when the request comes from a browser. Set these env vars to keep the
click-through flow working.
AuthService::logoutAll(User $user) now requires Request $request as a
second argument.
ConditionalCsrf no longer exempts requests based on X-Client-Type.
Only Bearer-token requests bypass CSRF.
Minimum supported Laravel raised to ^12.0.
failed_attempts on each
wrong submission and invalidates the OTP after otp_max_attempts (default 5).logoutAll preserves the calling token/session so the response is not
401'd before it returns.extra_fields are stripped of role, roles,
is_admin, admin, email_verified_at, password, and
password_change_required before User::create().finalizeRegistration wrapped in a DB transaction.forgotPassword for unknown emails (performs a real
Hash::make to prevent timing-based email enumeration).Six bugs found during integration testing:
AuthServiceProvider did not correctly register package routes when the host
app had custom route caching.use imports in two controller classes caused 500 errors in PHP 8.3
strict mode.OtpService::create() did not clean up expired records before inserting a new
one, causing unique constraint violations on high-traffic apps.TokenService::issueRefreshToken() returned null on first-time logins.SessionService did not handle a missing jenssegers/agent gracefully.
Now falls back to the raw User-Agent string.AuthRolesSeeder threw when the roles table did not exist. Now prints a
clear error with instructions.Initial public release.
Core authentication
POST /auth/register — initiates registration, sends OTP + magic link
simultaneously, returns temp_token.POST /auth/register/verify-otp — completes registration via OTP code.GET /auth/register/verify-magic/{token} — completes registration via
signed magic link.POST /auth/email/resend-verification — resends OTP/magic link
(email-enumeration-safe).POST /auth/login — authenticates verified users, issues Sanctum Bearer
token or session cookie depending on AUTH_MODE.POST /auth/logout — revokes current token/session.POST /auth/logout/all — revokes all sessions across all devices.GET /auth/me — returns user profile, roles, permissions, and active session
count.Password management
POST /auth/password/forgot — sends OTP + magic link for password reset
(email-enumeration-safe).POST /auth/password/reset/otp — validates OTP, issues reset_token.GET /auth/password/reset/magic/{token} — validates signed URL, issues
reset_token.POST /auth/password/reset/confirm — sets new password using reset_token,
revokes all existing sessions.POST /auth/password/change — changes password for authenticated user.Session & device tracking
GET /auth/sessions — lists all active sessions with device, browser, OS,
IP, and geo info.DELETE /auth/sessions/{id} — revokes a specific session by ID.User-Agent (web) and X-Device-Info header (mobile).resources/devices.json.API token system
GET|POST|DELETE /auth/api-tokens — user-scoped token management.GET|POST|PATCH|DELETE /auth/admin/api-tokens — admin token management.ApiTokenAuth middleware with per-ability checks.Google OAuth
GET /auth/social/google/redirect — returns Google authorization URL.GET /auth/social/google/callback — handles OAuth exchange; creates, links,
or logs in user.Real-time verification via Reverb
EmailVerified on auth.verification.{temp_token} private channel
when Reverb is enabled.Security
SuspiciousLoginDetected event + NewDeviceLoginNotification.RequireEmailVerified and DeviceFingerprint middleware.Infrastructure
AuthServiceProvider with Laravel auto-discovery.php artisan auth:install — publishes config, migrations, seeder, channel stub.AuthRolesSeeder — creates super-admin, admin, user roles.CleanExpiredOtpRecords (every 5 min) and CleanExpiredApiTokens (every hour).ResponseFormatterContract.OtpChannelContract.Mail::fake() and Queue::fake().How can I help you explore Laravel packages today?