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.
Version history for joe-404/laravel-auth. Every release is documented — what was added, what was fixed, and whether the version should be used in production.
Outdated versions are marked with a warning. Do not pin to them. They contain known bugs and missing security patches. Always use the latest patch release in the current minor series.
v2.6.1 → v2.7.3 — see root docs for full notes
This file's per-version sections currently stop at v2.6.0. For full per-version notes on v2.6.1, v2.7.0, v2.7.1, v2.7.2, and v2.7.3, see the canonical
UPGRADING.mdandCHANGELOG.mdat the repo root.Quick summary of what's new since v2.6.0:
- v2.6.1 — security hardening: social/reset 2FA gates,
auth.step-upmiddleware, HMAC OTP, fragment tokens,auth.activeon admin groups, password floor 15.- v2.7.0 — ⚠ mis-tagged on the v2.6.1 commit by mistake;
composer require :2.7.0actually installs v2.6.1 code. Do not pin to it — use v2.7.3.- v2.7.1 — the real v2.7 security pass: email-2FA strict-DB fix, timing-safe login, refresh hash strip, UUID-safe step-up cache keys, TOTP replay protection, switch + password-confirm rate limits,
APP_KEYboot guard, denylist hardening,required2FA enforcement on package routes, opt-in strict API-token abilities, admin role hierarchy, OAuthstatefor stateless clients, configurable trusted-device level,security.profilepreset (relaxed/balanced/high).- v2.7.2 — concurrency hardening: atomic single-use for backup codes, phone OTP, and TOTP replay; admin API-token PATCH/DELETE step-up symmetry.
- v2.7.3 — atomic 2FA challenge consumption across factors; admin referral routes through
AdminGate; deleted-account snapshot strips sensitive fields; optional user-side revoke step-up.The per-version sections below will be migrated into this file in a dedicated docs sprint.
v2.6 is additive — existing users without 2FA enrolled see no flow changes, no API breakage. The upgrade is one command plus a small User-model edit.
composer update joe-404/laravel-auth
Two new dependencies are pulled in automatically:
pragmarx/google2fa: ^8.0 — RFC 6238 TOTP implementationbacon/bacon-qr-code: ^3.0 — QR-code SVG generator for authenticator-app enrollmentphp artisan auth:install --upgrade
This runs only the new 2025_v260_* migrations (it skips re-publishing Sanctum/Spatie migrations) and prints a feature summary. New tables:
auth_two_factor_methodsauth_two_factor_backup_codesauth_two_factor_challengesauth_trusted_devicesauth_phone_otp_codesNew columns on users: phone, phone_verified_at, two_factor_required.
protected $fillable = [
// …existing…
'phone', 'phone_verified_at', 'two_factor_required',
];
protected $casts = [
// …existing…
'phone_verified_at' => 'datetime',
'two_factor_required' => 'bool',
];
php artisan vendor:publish --tag=auth-config --force
Three new sections appear — phone, two_factor, trusted_devices. All v2.6 features are disabled or non-intrusive by default; your app behaves exactly as on v2.5 until you opt in.
Phone capture + verification. Default driver is log (writes codes to the Laravel log — dev only). In production set a real provider:
AUTH_PHONE_ENABLED=true
AUTH_PHONE_REQUIRED=false # nullable at registration
AUTH_PHONE_VERIFY_AT_REG=false # verify later, not at register
AUTH_PHONE_SMS_PROVIDER=infobip
INFOBIP_API_KEY=...
INFOBIP_BASE_URL=https://api.infobip.com
Built-in providers: log, infobip, messagecentral, twilio, firebase. Custom providers register via PhoneDriverManager::extend() — see docs/customization.md.
Two-factor authentication.
AUTH_2FA_ENABLED=true # on by default
AUTH_2FA_REQUIRED=false # per-user opt-in, not forced
AUTH_2FA_DEFAULT=totp # totp | email | sms
AUTH_2FA_MIDDLEWARE=password_confirm # block | force_enroll | password_confirm
Enrollment (post-login): POST /auth/2fa/enroll/totp/start → scan QR → POST /auth/2fa/enroll/totp/verify. The first method's verify response returns backup_codes once.
Login becomes a two-step flow once 2FA is enrolled:
POST /auth/login → { requires_2fa, challenge_token, available_methods }
POST /auth/2fa/challenge → { token, refresh_token, trusted_device_token? }
Trusted devices.
AUTH_TRUSTED_DEVICES_ENABLED=true
AUTH_TRUST_BYPASS_MIN=high # devices >= high skip the 2FA challenge
AUTH_TRUST_LEVEL_MODE=time # time | time_consistent | time_admin
Trusted-device 2FA bypass requires two signals — not fingerprint alone. When a device is trusted, the package issues a one-time trusted_device_token (returned in the registration response and in /auth/2fa/challenge when trust_device=true). The client must send it back as the X-Trusted-Device-Token header — together with X-Browser-Fingerprint — for the bypass to apply. Fingerprint is client-controlled and never bypasses on its own. Store the token in mobile Keychain or an HttpOnly cookie; it is returned exactly once.
Social sign-in when you require custom fields. OAuth (Google) gives you the user's email + name but never your app's required fields (username, phone, country…). Enable profile completion so a brand-new social user is asked for those fields before the account is created:
AUTH_SOCIAL_PROFILE_COMPLETION=true # default false (legacy: create + log in immediately)
AUTH_SOCIAL_PROFILE_COMPLETION_TTL=15 # minutes the completion token is valid
With it on, the callback for a brand-new user returns a completion step instead of a token:
GET /auth/social/google/callback
→ 202 { requires_profile_completion: true, completion_token, prefill: { email, name, avatar } }
POST /auth/social/complete { completion_token, username, phone, … }
→ validates the SAME registration.extra_fields_rules + phone rules as the email flow
→ creates the user, links the social account, issues the real token
No user row is created until /auth/social/complete succeeds — an abandoned onboarding leaves nothing behind, exactly like the 3-step email flow. Only required fields block; optional ones can be filled later. Existing users (and all users when the flag is off) keep logging in directly.
Route::middleware(['auth:sanctum', 'auth.2fa'])->group(function () {
Route::delete('billing/subscription', /* … */);
});
auth.2fa issues a fresh 2FA challenge (or password-confirm fallback for users without 2FA). See docs/middleware.md for the full middleware reference.
| Behavior | v2.5 | v2.6 |
|---|---|---|
| Login, 0 enrolled 2FA methods | issues token | unchanged |
| Login, ≥1 enrolled 2FA method | n/a | returns challenge_token first |
| Trusted device w/ fingerprint + token ≥ bypass level | n/a | skips 2FA, issues token |
New Google user, profile_completion off |
creates + logs in from Google profile | unchanged |
New Google user, profile_completion on |
n/a | returns requires_profile_completion; account created only at /auth/social/complete |
UserLoggedIn event |
fires at login | still fires at credential success even when 2FA pending; new TwoFactorChallengeIssued + TwoFactorVerified events added |
php artisan migrate:rollback --step=7
composer require joe-404/laravel-auth:^2.5.1
No breaking changes, no migrations. This is a security and correctness pass on the refresh-token flow plus a handful of hardening fixes.
composer require joe-404/laravel-auth:^2.5
php artisan vendor:publish --tag=auth-config --force # pick up new optional keys
Behavior changes you should be aware of:
Refresh now re-checks account state. A suspended, disabled, soft-deleted, or unverified user calling POST /auth/refresh will be rejected — they used to get a fresh token pair. Frontends should handle the same error responses they already handle on login.
Refresh now keeps the session row in sync. After rotation, auth_sessions_extended.sanctum_token_id is repointed at the new access token. Sessions listings, DELETE /auth/sessions/{id}, and last-active tracking now keep working past the first refresh.
GeoIP lookup is now queued. If you have auth_system.device.resolve_location=true, the country/city resolution is now dispatched as a BackfillSessionLocation job. The session row is created with country/city = null and the values fill in once the queue worker runs. A queue worker must be running for the columns to populate. The endpoint is now HTTPS (https://ip-api.com/json/{ip} by default).
X-Browser-Fingerprint is now format-validated. Values that are not hex digests within [32, 128] characters are silently ignored (treated as absent). If your frontend was sending something other than a hex digest, switch to one — e.g. SHA-256 of the canvas/WebGL/screen signals.
ApiTokenAuth no longer echoes raw exception messages. Clients calling endpoints behind auth.api-token will see "Invalid API token." for unknown errors instead of the underlying exception message. The original is logged via Log::error.
isAuthRoute() now respects AUTH_ROUTES_PREFIX. If you mounted the package under a custom prefix (e.g. api/v1/auth) on v2.5.0 or earlier, validation and authentication failures on those routes were not being wrapped in the package JSON envelope. They are now.
New optional .env / config keys (safe to leave at defaults):
# When true (default), POST /auth/refresh rejects unverified users.
# Set false to keep legacy behavior (verification only enforced at login).
AUTH_VERIFICATION_REQUIRED_FOR_REFRESH=true
// config/auth_system.php
'verification' => [
'required_for_refresh' => env('AUTH_VERIFICATION_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',
],
The free
ip-api.complan only allows HTTP. If you are using the free plan, either overridedevice.location_endpointback tohttp://ip-api.com/json/{ip}(note: cleartext transport) or switch to a provider that supports HTTPS on the free tier.
No breaking changes. Run migrations, publish the updated config to pick up the new referral_code and device keys.
composer require joe-404/laravel-auth:^2.5
php artisan migrate
php artisan vendor:publish --tag=auth-config --force
New .env variables (all optional — safe to ignore if features not needed):
# Referral codes
AUTH_REFERRAL_CODE_ENABLED=false
AUTH_REFERRAL_CODE_LENGTH=10
AUTH_REFERRAL_CODE_UPPERCASE=true
AUTH_REFERRAL_REDEEM_WINDOW=120
AUTH_REFERRAL_ALLOWED_CLIENTS=both
AUTH_REFERRAL_ABUSE_SAME_IP=flag
AUTH_REFERRAL_ABUSE_SAME_DEVICE=block
AUTH_REFERRAL_ABUSE_BOTH=block
New tables created by migrations:
referrals — referral relationship, status, fingerprint snapshots, abuse flagsauth_user_devices — permanent per-user device history (survives logout)fingerprint_hash column added to auth_sessions_extendedNo breaking changes for most apps. Run migrations and publish the updated config.
composer require joe-404/laravel-auth:^2.4
php artisan migrate
php artisan vendor:publish --tag=auth-config --force # pick up new account.* keys
Recommended User model changes:
use Illuminate\Database\Eloquent\SoftDeletes;
use Joe404\LaravelAuth\Concerns\HasAccountStatus;
class User extends Authenticatable
{
use HasApiTokens, HasRoles, Notifiable, SoftDeletes, HasAccountStatus;
}
SoftDeletes is required if you want account deletion auto-restore to work. HasAccountStatus is optional sugar.
New schema columns added to users:
account_status (varchar 32, default active)status_changed_at (timestamp, nullable)status_reason (text, nullable)status_expires_at (timestamp, nullable)deleted_at (timestamp, nullable — SoftDeletes column)New table: account_status_logs — stores the audit trail for status changes and admin notes.
New table: deleted_accounts — stores snapshots of deleted user rows during grace.
One breaking change if you instantiate package exceptions directly.
The constructor signature of AuthException and all subtypes changed:
// Before (v2.1.x)
new OtpInvalidException(string $message, int $code = 0, ?Throwable $previous = null);
// After (v2.3.x)
new OtpInvalidException(string $message, ?string $errorKey = null, array $replacements = [], ?Throwable $previous = null);
If you only catch package exceptions (which is the normal case), no change needed. $e->getMessage() still works.
If you throw package exceptions in your own code (unusual), update the constructor arguments.
Multiple breaking changes. See the v2.0.0 section below for the full list.
Tag: v2.6.0 | Released: 2026-05-25
Phone capture + verification, full two-factor authentication (TOTP / Email / SMS) with backup codes, and a trusted-device system with time-based trust levels. New auth.2fa step-up middleware. Additive — users without 2FA enrolled see no flow changes.
log (dev), infobip, messagecentral, twilio, firebase, and custom drivers via PhoneDriverContract + PhoneDriverManager::extend(). Per-channel (sms/voice/whatsapp) provider selection with optional fallback driver.pragmarx/google2fa, server-rendered QR), Email OTP, and SMS OTP. Multiple methods enrollable in parallel; the user picks any at challenge time.challenge_token instead of a token; POST /auth/2fa/challenge completes it. Method switching + resend supported.low/medium/high); three assignment modes; revocation matrix. 2FA bypass requires both the device fingerprint and a server-issued X-Trusted-Device-Token — fingerprint alone never bypasses.Require2FA middleware (auth.2fa) — GitHub-style step-up for sensitive endpoints, with block / force_enroll / password_confirm fallbacks.social.profile_completion.enabled is true, a brand-new OAuth user missing the host's required fields gets a requires_profile_completion step (POST /auth/social/complete) instead of being created immediately, enforcing the same extra_fields_rules + phone rules as the email flow. No user row until completion.POST /auth/password/confirm grants a short step-up window for the password_confirm middleware path.Request::authContext() — read-only snapshot of 2fa_enabled, 2fa_verified, trust_level, phone_verified, sudo_active.auth:install --upgrade — runs only the new v2.6 migrations and prints a feature summary.PhoneVerified, TwoFactorEnrolled, TwoFactorDisabled, TwoFactorVerified, TwoFactorChallengeIssued, TwoFactorChallengeFailed, TrustedDeviceAdded, TrustedDeviceRevoked.auth_two_factor_methods, auth_two_factor_backup_codes, auth_two_factor_challenges, auth_trusted_devices, auth_phone_otp_codes. New users columns: phone, phone_verified_at, two_factor_required.
pragmarx/google2fa: ^8.0, bacon/bacon-qr-code: ^3.0.
UserLoggedIn still fires at credential success even when a 2FA challenge is pending (preserves v2.5 listener semantics). TwoFactorChallengeIssued fires when the challenge is created; TwoFactorVerified fires on completion.trusted_devices.bypass_2fa_min_level is high (the strongest trust signal) — override with AUTH_TRUST_BYPASS_MIN=medium for looser UX.Anyone who wants phone verification, 2FA, or trusted devices. Pure additive — safe for existing v2.5 deployments. See the Upgrading to v2.6.0 from v2.5.x steps above.
Tag: v2.5.1 | Released: 2026-05-22
TokenService::refresh() used to mint new tokens without re-checking whether the user was still allowed to authenticate, so 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() (gated by the new verification.required_for_refresh key), and trashed() before issuing the new pair.consumed check and produced "Invalid refresh token" without the family revoke. The row is now lockForUpdate-selected inside the transaction; any presentation of a consumed token revokes the whole family (RFC 6749 §10.4 strict rotation). The revoke runs after the rotation transaction commits, so its writes cannot be rolled back by the throw that follows.AuthService::refreshToken() rotated the Sanctum token but left auth_sessions_extended.sanctum_token_id pointing at the now-deleted old token, silently breaking /auth/sessions, session revocation, and last-active tracking after the first refresh. The session row is now re-pointed (or created if missing) and last_active_at is bumped.AuthServiceProvider::isAuthRoute() used to hardcode auth/, so hosts mounted under api/v1/auth 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 that start with auth..magic_link_target=frontend with an empty or malformed frontend_verify_url / frontend_reset_url used to produce emails with broken links (?token=... and no host). The package now throws AuthConfigurationException at link-generation time so misconfigurations fail loudly in staging instead of silently in production.ApiTokenAuth no longer leaks raw 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. Known AuthException subclasses still pass through with their safe message; everything else is logged and replaced with a generic "Invalid API token.".X-Browser-Fingerprint used to be accepted verbatim (truncated to 191 chars). It must now be a hex digest within [browser_fingerprint_min_length, browser_fingerprint_max_length] characters (defaults 32–128) — otherwise it is treated as absent. The fingerprint is still advisory and must not be treated as proof of device identity.http:// on every login, adding up to 3s of latency per request. It now runs as the new BackfillSessionLocation queued job, which fills country/city on the session row after it is created. The default endpoint is https://ip-api.com/json/{ip} and is overridable via auth_system.device.location_endpoint.use Mockery; statement in tests/Feature/Auth/SocialAuthTest.php that was producing a PHP warning on every test run (Mockery is in the root namespace, so the import had no effect).AuthConfigurationException — typed exception for programmer-facing misconfiguration. Default error key: auth_misconfigured.BackfillSessionLocation job — queues GeoIP lookups off the auth request path. Dispatched only when device.resolve_location=true, the IP is public, and the session row was not pre-populated by a host-app resolver.| Key | Default | Purpose |
|---|---|---|
verification.required_for_refresh |
true |
Whether POST /auth/refresh requi... |
How can I help you explore Laravel packages today?