joe-404 / laravel-auth
Drop-in, config-driven authentication library for Laravel 13. Registration, OTP + magic-link verification, login, password reset, sessions, API tokens, Google OAuth, and real-time Reverb verification — all through a single JSON API.
Requires
- php: ^8.2
- jenssegers/agent: ^2.6
- laravel/framework: ^13.0
- laravel/sanctum: ^4.0
- laravel/socialite: ^5.0
- spatie/laravel-permission: ^6.0
Requires (Dev)
- orchestra/testbench: ^9.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
README
A drop-in, config-driven authentication library for Laravel 13.
Install once, configure via .env, and get a production-ready authentication system with:
- Registration with OTP + magic-link email verification (sent simultaneously)
- Login / Logout — Bearer token (API), session cookie (SPA), or auto-detected (
bothmode) - Password reset via OTP or signed magic link
- Session & device tracking — browser, OS, device model, IP, city, country
- API token system — scoped, optionally expiring tokens for third-party clients
- Google OAuth via Laravel Socialite
- Real-time verification broadcast via Laravel Reverb
- Security hardening — dual-layer rate limiting, account lockout, new-device email alerts
- 100% JSON API — consistent
{ success, message, data }envelope on every response
One
composer require. Onephp artisan auth:install. Zero boilerplate.
Why joe-404/laravel-auth?
| What you'd normally build manually | What this package gives you |
|---|---|
| Registration + OTP + magic link logic | Single POST /auth/register with both sent simultaneously |
| Custom session/device fingerprinting | Built-in auth_sessions_extended table + X-Device-Info header |
| Rate limiting per IP and per email | Configured per endpoint via .env |
| Account lockout across rate windows | Cumulative Redis counter, independent of per-window limits |
| API token system (scoped, expiring) | Full CRUD + ApiTokenAuth middleware with ability checks |
| Google OAuth → link or create user | GET /auth/social/google/callback handles all three cases |
| Real-time auth events in the browser | EmailVerified broadcast on auth.verification.{temp_token} |
| Custom response envelope | ResponseFormatterContract — override without touching library code |
| Custom OTP delivery (SMS, WhatsApp) | OtpChannelContract — swap the channel in one line |
Table of Contents
- Requirements
- Installation
- Configuration Reference
- Authentication Modes
- API Endpoints
- Response Envelope
- Customising the Response Format
- Customising the OTP Channel
- Security Features
- Real-time Verification (Reverb)
- Device & Session Tracking
- Admin API Token Management
- Role Assignment
- Events Reference
- Scheduled Jobs
- Environment Variable Quick Reference
- Extending the Library
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | ^13.0 |
| laravel/sanctum | ^4.0 |
| laravel/socialite | ^5.0 |
| spatie/laravel-permission | ^6.0 |
| jenssegers/agent | ^2.6 |
| A cache driver | Redis recommended, array for testing |
Installation
Step 1 — Require the package
composer require joe-404/laravel-auth
Step 2 — Run the install command
php artisan auth:install
This command:
- Publishes
config/auth_system.php - Publishes all database migrations into
database/migrations/ - Publishes the role seeder into
database/seeders/ - Appends the Reverb channel stub to
routes/channels.php(for real-time verification) - Prints a checklist of next steps
Step 3 — Run migrations
php artisan migrate
The package creates or modifies the following tables:
| Table | Purpose |
|---|---|
users (modified) |
Adds google_id, is_active, last_login_at columns |
auth_otp_codes |
Stores OTP and magic-link verification tokens |
auth_sessions_extended |
Device and session tracking per user |
auth_social_accounts |
Google OAuth account links |
auth_api_tokens |
Third-party API token management |
Step 4 — Seed roles
php artisan db:seed --class=AuthRolesSeeder
This creates the three built-in roles: super-admin, admin, user.
Step 5 — Configure your .env
At a minimum, add:
AUTH_MODE=api # api | web | both AUTH_GOOGLE_ENABLED=false AUTH_REVERB_ENABLED=false
Configuration Reference
After publishing, the full config lives in config/auth_system.php. Every key maps to an environment variable. Below is a detailed explanation of every option.
mode
Env: AUTH_MODE | Default: both
Controls how authentication tokens are issued.
| Value | Behaviour |
|---|---|
api |
Always issues a Sanctum Bearer token. Every response includes data.token. |
web |
Uses Laravel session cookies only. data.token is always null. |
both |
Auto-detects: sends a Bearer token when the request contains X-Client-Type: mobile header or Accept: application/json. Falls back to session cookie for browser requests. |
Example:
AUTH_MODE=api
verification
Controls email verification after registration.
verification.method
Env: AUTH_VERIFICATION_METHOD | Default: both
| Value | Behaviour |
|---|---|
otp |
Sends a numeric OTP code to the user's email. User verifies by POSTing the code. |
magic_link |
Sends a signed URL to the user's email. User clicks the link to verify. |
both |
Sends both simultaneously. User uses whichever arrives first. |
verification.otp_length
Env: AUTH_OTP_LENGTH | Default: 6
Number of digits in the OTP code. Accepted values: 4–8.
verification.otp_expiry
Env: AUTH_OTP_EXPIRY | Default: 10
Minutes before the OTP code expires.
verification.magic_expiry
Env: AUTH_MAGIC_EXPIRY | Default: 30
Minutes before the magic link URL expires.
Example:
AUTH_VERIFICATION_METHOD=otp AUTH_OTP_LENGTH=6 AUTH_OTP_EXPIRY=10 AUTH_MAGIC_EXPIRY=30
token
token.expiration_minutes
Env: AUTH_TOKEN_EXPIRY | Default: 10080 (7 days)
How long a Sanctum Bearer token remains valid. Set to 0 for no expiry.
Example:
AUTH_TOKEN_EXPIRY=1440 # 24 hours
rate_limits
Protects endpoints against abuse. Format is "max_attempts:decay_minutes".
| Key | Env | Default | Protects |
|---|---|---|---|
register |
AUTH_RATE_REGISTER |
5:1 |
POST /auth/register |
login |
AUTH_RATE_LOGIN |
5:1 |
POST /auth/login |
otp_send |
AUTH_RATE_OTP_SEND |
3:1 |
POST /auth/email/resend-verification |
password_reset |
AUTH_RATE_PASSWORD_RESET |
3:1 |
POST /auth/password/forgot |
Rate limiting checks both IP address and email address independently. If either is over the limit the request is blocked with HTTP 429.
Example — stricter login:
AUTH_RATE_LOGIN=3:5 # 3 attempts per 5 minutes
password
password.min_length
Env: AUTH_PASSWORD_MIN | Default: 8
Minimum password length enforced at the request validation layer.
password.require_uppercase
Env: AUTH_PASSWORD_UPPERCASE | Default: false
Require at least one uppercase letter.
password.require_number
Env: AUTH_PASSWORD_NUMBER | Default: false
Require at least one number.
password.require_special
Env: AUTH_PASSWORD_SPECIAL | Default: false
Require at least one special character.
password.pending_ttl_minutes
Env: AUTH_PENDING_TTL | Default: 60
How long (in minutes) the pre-registration cache entry lives. During registration, the user's password hash is stored in cache until email verification is complete. If they do not verify within this window, they must register again.
Example:
AUTH_PASSWORD_MIN=12 AUTH_PASSWORD_UPPERCASE=true AUTH_PASSWORD_NUMBER=true AUTH_PENDING_TTL=30
require_email_verification
Env: AUTH_REQUIRE_VERIFICATION | Default: true
When true, users must verify their email before they can log in. Setting this to false allows login immediately after registration (useful for internal tools).
AUTH_REQUIRE_VERIFICATION=false
roles
roles.default_role
Env: AUTH_DEFAULT_ROLE | Default: user
The Spatie role name automatically assigned to every new user (both via standard registration and Google OAuth).
roles.seeded_roles
Array of roles created by AuthRolesSeeder. Default: ['super-admin', 'admin', 'user']. Edit the seeder to add custom roles.
Example:
AUTH_DEFAULT_ROLE=member
otp_channel
otp_channel.driver
Env: AUTH_OTP_CHANNEL | Default: email
The channel used to deliver OTP codes and magic links. Set to email to use the built-in EmailOtpChannel, or provide a fully-qualified class name that implements Joe404\LaravelAuth\Contracts\OtpChannelContract to use SMS, WhatsApp, etc.
See Customising the OTP Channel.
social
social.google.enabled
Env: AUTH_GOOGLE_ENABLED | Default: false
Enables the Google OAuth endpoints. Also requires:
AUTH_GOOGLE_ENABLED=true GOOGLE_CLIENT_ID=your-client-id GOOGLE_CLIENT_SECRET=your-client-secret GOOGLE_REDIRECT_URI=https://your-app.com/auth/social/google/callback
The library auto-configures services.google from these values — you do not need to modify config/services.php.
reverb
reverb.enabled
Env: AUTH_REVERB_ENABLED | Default: false
When true and Laravel Reverb is installed, the library broadcasts an EmailVerified event on the private channel auth.verification.{temp_token} when a user completes email verification. This allows your frontend to react in real-time without polling.
queue
queue.connection
Env: AUTH_QUEUE_CONNECTION | Default: null (uses app default)
Queue connection for background maintenance jobs.
queue.name
Env: AUTH_QUEUE_NAME | Default: auth-maintenance
Queue name for background maintenance jobs.
response
response.formatter
Env: AUTH_RESPONSE_FORMATTER | Default: null
Fully-qualified class name of a custom response formatter. See Customising the Response Format.
security
security.notify_new_device_login
Env: AUTH_NOTIFY_NEW_DEVICE | Default: true
When true, sends an email notification to the user when they log in from a device (browser + OS combination) that has not been seen before. Uses the NewDeviceLoginNotification mailable.
security.lockout.enabled
Env: AUTH_LOCKOUT_ENABLED | Default: true
Enables account-level lockout. Unlike rate limiting (which blocks by request rate), account lockout tracks cumulative failures across multiple rate-limit windows. Once max_attempts are reached, the account is locked for decay_minutes regardless of IP address.
security.lockout.max_attempts
Env: AUTH_LOCKOUT_MAX | Default: 10
Number of failed login attempts before the account is locked.
security.lockout.decay_minutes
Env: AUTH_LOCKOUT_DECAY | Default: 15
How long the lockout lasts in minutes.
Example:
AUTH_NOTIFY_NEW_DEVICE=true AUTH_LOCKOUT_ENABLED=true AUTH_LOCKOUT_MAX=5 AUTH_LOCKOUT_DECAY=30
Authentication Modes
The library serves three client types through a single auth:sanctum guard:
| Client type | How to authenticate |
|---|---|
| SPA (browser) | Laravel session cookie. No Authorization header needed. |
| Mobile / API | Authorization: Bearer {token} header on every request. |
| Third-party services | Authorization: Bearer auth_at_{token} — uses the API token system, not Sanctum. |
For AUTH_MODE=both, the library auto-detects the client type:
- Requests with
X-Client-Type: mobileheader → Bearer token - Requests with
Accept: application/json→ Bearer token - Everything else → session cookie
API Endpoints
All routes are prefixed with /auth. Base URL example: https://your-app.com/auth.
All requests and responses use Content-Type: application/json.
Registration
POST /auth/register
Initiates registration. Sends OTP and/or magic link to the provided email. Does not create a user record yet — that happens on verification.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | User's email address |
password |
string | Yes | Min 8 characters |
password_confirmation |
string | Yes | Must match password |
Success response — 201:
{
"success": true,
"message": "Verification sent. Please check your email.",
"data": {
"temp_token": "550e8400-e29b-41d4-a716-446655440000",
"method": "both",
"expires_in": 10
}
}
| Field | Description |
|---|---|
temp_token |
UUID used to subscribe to the Reverb real-time channel auth.verification.{temp_token} |
method |
Which verification method(s) were sent (otp, magic_link, or both) |
expires_in |
Minutes until the OTP/link expires |
Error responses:
| Status | When |
|---|---|
| 409 | Email already registered |
| 422 | Validation failed |
| 429 | Rate limit exceeded |
POST /auth/register/verify-otp
Completes registration by submitting the OTP code received by email.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | Same email used in /register |
otp |
string | Yes | The numeric code from the email |
Success response — 201:
{
"success": true,
"message": "Registration complete.",
"data": {
"user": { "id": 1, "name": "joe", "email": "joe@example.com", "..." },
"token": "1|abc123...",
"temp_token": "550e8400-..."
}
}
token is null when AUTH_MODE=web.
Error responses:
| Status | When |
|---|---|
| 422 | OTP is wrong or expired |
GET /auth/register/verify-magic/{token}
Completes registration by clicking the magic link from the email. The {token} is the UUID embedded in the link by the library — users never construct this URL manually; they simply click the link in their inbox.
Success response — 201: Same structure as verify-otp.
Error responses:
| Status | When |
|---|---|
| 422 | Link signature is invalid, link is expired, or token was already used |
Email Verification
POST /auth/email/resend-verification
Resends OTP and/or magic link for a registration that has not yet been verified. Generates a new temp_token (invalidating the previous Reverb subscription). Always returns HTTP 200 regardless of whether the email exists — this prevents email enumeration.
Request body:
| Field | Type | Required |
|---|---|---|
email |
string | Yes |
Success response — 200:
{
"success": true,
"message": "If a pending registration exists for that email, a new verification has been sent.",
"data": {}
}
Login
POST /auth/login
Authenticates an existing, verified user.
Request body:
| Field | Type | Required |
|---|---|---|
email |
string | Yes |
password |
string | Yes |
Optional headers:
| Header | Value | Effect |
|---|---|---|
X-Client-Type |
mobile |
Forces Bearer token response even in AUTH_MODE=both |
X-Device-Info |
JSON string (see below) | Identifies mobile device for session tracking |
X-Device-Info JSON format (mobile clients):
{
"model": "SM-G991B",
"platform": "android",
"os_version": "14"
}
Success response — 200:
{
"success": true,
"message": "Logged in successfully.",
"data": {
"user": {
"id": 1,
"name": "Joe",
"email": "joe@example.com",
"email_verified_at": "2025-01-01T00:00:00.000000Z",
"last_login_at": "2025-05-08T12:00:00.000000Z"
},
"token": "2|xyz789..."
}
}
token is null for web session logins.
Error responses:
| Status | When |
|---|---|
| 401 | Wrong email or password; or account locked out |
| 403 | Account inactive (is_active = false) |
| 403 | Email not verified (require_email_verification = true) |
| 422 | Validation failed |
| 429 | Rate limit exceeded |
Logout
All logout endpoints require authentication (Authorization: Bearer {token} or active session cookie).
POST /auth/logout
Revokes the current token/session only.
Success response — 200:
{
"success": true,
"message": "Logged out successfully.",
"data": {}
}
POST /auth/logout/all
Revokes all sessions and tokens for the authenticated user across all devices.
Success response — 200:
{
"success": true,
"message": "All sessions have been terminated.",
"data": {}
}
Current User
GET /auth/me
Returns the authenticated user's profile, roles, permissions, and active session count.
Requires: Authentication.
Success response — 200:
{
"success": true,
"message": "User retrieved.",
"data": {
"user": { "id": 1, "name": "Joe", "email": "joe@example.com" },
"roles": ["user"],
"permissions": ["read-posts", "create-posts"],
"active_sessions": 2
}
}
Password Reset
Password reset is a two-step process: request → verify → set new password.
Step 1 — POST /auth/password/forgot
Sends OTP and/or magic link to the email. Always returns 200 to prevent email enumeration.
Request body:
| Field | Type | Required |
|---|---|---|
email |
string | Yes |
Success response — 200:
{
"success": true,
"message": "If that email is registered, you will receive reset instructions shortly.",
"data": {}
}
Step 2a — Reset via OTP: POST /auth/password/reset/otp
Submit the OTP from the email along with the new password in one call.
Request body:
| Field | Type | Required |
|---|---|---|
email |
string | Yes |
otp |
string | Yes |
password |
string | Yes |
password_confirmation |
string | Yes |
Success response — 200:
{
"success": true,
"message": "Password reset successfully. Please log in with your new password.",
"data": {}
}
Error responses:
| Status | When |
|---|---|
| 422 | OTP invalid, expired, or user not found |
Step 2b — Reset via magic link (two parts):
Part 1 — GET /auth/password/reset/magic/{token}
The user clicks the link in their email. This validates the signed URL and returns a short-lived reset_token UUID.
Success response — 200:
{
"success": true,
"message": "Link validated. Submit your new password using the reset_token.",
"data": {
"reset_token": "a1b2c3d4-e5f6-..."
}
}
The reset_token is valid for 15 minutes.
Part 2 — POST /auth/password/reset/confirm
Submit the reset_token with the new password.
Request body:
| Field | Type | Required |
|---|---|---|
reset_token |
string (UUID) | Yes |
password |
string | Yes |
password_confirmation |
string | Yes |
Success response — 200:
{
"success": true,
"message": "Password reset successfully. Please log in with your new password.",
"data": {}
}
After a successful password reset, all existing tokens and sessions are revoked automatically.
Password Change
POST /auth/password/change
Changes the password for the currently authenticated user.
Requires: Authentication.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
current_password |
string | Yes | Must match the stored hash |
new_password |
string | Yes | Min 8 chars, must differ from current |
new_password_confirmation |
string | Yes | |
logout_all |
boolean | No | When true, revokes all other sessions/tokens (keeps current session active) |
Success response — 200:
{
"success": true,
"message": "Password changed successfully.",
"data": {}
}
Session Management
GET /auth/sessions
Lists all active sessions for the authenticated user.
Requires: Authentication.
Success response — 200:
{
"success": true,
"message": "Sessions retrieved.",
"data": {
"sessions": [
{
"id": 1,
"platform": "api",
"browser": "Chrome",
"os": "Windows",
"device_model": null,
"device_marketing_name": null,
"ip_address": "203.0.113.1",
"country": "Lebanon",
"city": "Beirut",
"last_active_at": "2025-05-08T12:00:00.000000Z",
"is_current": true
}
]
}
}
DELETE /auth/sessions/{id}
Revokes a specific session by its ID.
Requires: Authentication.
Success response — 200:
{
"success": true,
"message": "Session terminated.",
"data": {}
}
API Token Management
API tokens are long-lived, scoped tokens for third-party integrations (CI pipelines, external services, mobile SDKs). They are distinct from Sanctum session tokens.
Token format: auth_at_{base64_encoded_random}
Only the SHA-256 hash of the raw token is stored. Show the raw token to the user once immediately after creation — it cannot be recovered.
GET /auth/api-tokens
Lists all API tokens owned by the authenticated user.
Requires: Authentication + AUTH_MODE must be api or both.
Success response — 200:
{
"success": true,
"message": "API tokens retrieved.",
"data": {
"tokens": [
{
"id": 1,
"name": "CI Pipeline",
"abilities": ["read", "deploy"],
"last_used_at": "2025-05-07T09:00:00Z",
"expires_at": null,
"is_active": true
}
]
}
}
POST /auth/api-tokens
Creates a new API token.
Requires: Authentication + AUTH_MODE must be api or both.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Human-readable label |
abilities |
array of strings | No | Defaults to ["read"]. Use ["*"] for full access. |
expires_in_days |
integer | No | null = never expires. Max 3650. |
Success response — 201:
{
"success": true,
"message": "API token created. Store it securely — it will not be shown again.",
"data": {
"raw_token": "auth_at_dGhpcyBpcyBhIHRlc3Q...",
"token": {
"id": 1,
"name": "CI Pipeline",
"abilities": ["read", "deploy"],
"expires_at": null,
"is_active": true
}
}
}
DELETE /auth/api-tokens/{id}
Revokes one of the authenticated user's API tokens.
Requires: Authentication.
Success response — 200:
{
"success": true,
"message": "API token revoked.",
"data": {}
}
Using an API Token
Pass it in the Authorization header on requests to your own application endpoints. Your application middleware must include auth.api-token:
Authorization: Bearer auth_at_dGhpcyBpcyBhIHRlc3Q...
Add the middleware to your routes:
// Require any valid API token Route::middleware('auth.api-token')->group(function () { ... }); // Require specific abilities Route::middleware('auth.api-token:read,orders')->group(function () { ... });
Inside the controller, the resolved token is available as:
$request->get('_api_token'); // AuthApiToken model
Google OAuth
GET /auth/social/google/redirect
Returns the Google OAuth authorization URL. Your frontend should redirect the user to this URL.
Requires: AUTH_GOOGLE_ENABLED=true.
Success response — 200:
{
"success": true,
"message": "Redirect URL generated.",
"data": {
"redirect_url": "https://accounts.google.com/o/oauth2/auth?client_id=..."
}
}
Error response:
| Status | When |
|---|---|
| 403 | Google OAuth is disabled in config |
GET /auth/social/google/callback
Google redirects the user to this URL after authorization. The library handles the OAuth exchange automatically.
Three cases:
| Case | What happens |
|---|---|
Provider ID matches an existing auth_social_accounts record |
The user is logged in |
| No provider ID match but email matches an existing user | Google account is linked to the existing user, then logged in |
| Brand new user | A new user account is created (email pre-verified), role assigned, then logged in |
Success response — 200:
{
"success": true,
"message": "Authenticated via Google.",
"data": {
"user": { "id": 3, "email": "joe@gmail.com", "email_verified_at": "2025-05-08T12:00:00Z" },
"token": "3|abc..."
}
}
Error responses:
| Status | When |
|---|---|
| 403 | Google OAuth disabled, or account is inactive |
| 400 | OAuth exchange failed (Google error) |
Response Envelope
Every response from this library follows the same JSON structure:
Success:
{
"success": true,
"message": "Human-readable description.",
"data": { }
}
Failure:
{
"success": false,
"message": "Human-readable error description.",
"errors": { }
}
Customising the Response Format
If your application uses a different JSON structure (e.g., wrapping in { "status": "ok", "result": {} }), implement the ResponseFormatterContract:
namespace App\Auth; use Joe404\LaravelAuth\Contracts\ResponseFormatterContract; class MyFormatter implements ResponseFormatterContract { public function format(bool $success, string $message, array $data, array $errors): array { return [ 'status' => $success ? 'ok' : 'error', 'message' => $message, 'result' => $success ? $data : $errors, ]; } }
Then register it — two ways (first one wins):
Option A — config:
AUTH_RESPONSE_FORMATTER=App\Auth\MyFormatter
Option B — service container (in AppServiceProvider):
$this->app->bind( \Joe404\LaravelAuth\Contracts\ResponseFormatterContract::class, \App\Auth\MyFormatter::class, );
Customising the OTP Channel
By default OTP codes and magic links are delivered via email. To use SMS or another channel, implement OtpChannelContract:
namespace App\Auth; use Joe404\LaravelAuth\Contracts\OtpChannelContract; class SmsOtpChannel implements OtpChannelContract { public function sendOtp(string $email, string $code, array $context = []): void { // Look up phone number by email, send SMS } public function sendMagicLink(string $email, string $url, array $context = []): void { // Send the link via SMS } }
Register it:
AUTH_OTP_CHANNEL=App\Auth\SmsOtpChannel
Security Features
Rate Limiting
All public auth endpoints are protected by the auth.ratelimit middleware. Rate limits apply independently per IP address and per email address. If either is over the limit, a 429 Too Many Requests response is returned with a Retry-After header.
The response on rate limit:
{
"success": false,
"message": "Too many attempts. Please try again in 42 seconds.",
"errors": {}
}
Rate limits are cleared automatically on a successful response.
To customise limits:
AUTH_RATE_LOGIN=3:5 # 3 attempts per 5 minutes AUTH_RATE_REGISTER=10:1 # 10 per minute AUTH_RATE_OTP_SEND=2:5 # 2 resend attempts per 5 minutes AUTH_RATE_PASSWORD_RESET=2:10
Account Lockout
Account lockout is a second, independent layer on top of rate limiting. Where rate limiting blocks within a sliding window, lockout tracks cumulative failures across all windows for a specific email address.
Flow:
- User fails login → failure counter incremented in cache (key:
auth:lockout_count:{sha1(email)}) - Counter resets after
AUTH_LOCKOUT_DECAYminutes of inactivity - After
AUTH_LOCKOUT_MAXtotal failures, a lockout flag is set (key:auth:locked:{sha1(email)}) forAUTH_LOCKOUT_DECAYminutes - Any login attempt during lockout (including correct credentials) returns 401 with the locked message
- Successful login immediately clears both the counter and the lockout flag
Configuration:
AUTH_LOCKOUT_ENABLED=true AUTH_LOCKOUT_MAX=10 # lock after 10 cumulative failures AUTH_LOCKOUT_DECAY=15 # locked for 15 minutes
Locked-out response:
{
"success": false,
"message": "Account temporarily locked due to too many failed attempts. Try again in 15 minute(s).",
"errors": {}
}
New Device Detection
When a user successfully logs in from a device (browser + OS combination) that has no prior session in auth_sessions_extended, the library:
- Dispatches
SuspiciousLoginDetectedevent - The
NotifySuspiciousLoginlistener sends aNewDeviceLoginNotificationemail
The email includes:
- IP address of the new login
- Browser and OS (when available)
- City and country (from ip-api.com geo lookup)
Configuration:
AUTH_NOTIFY_NEW_DEVICE=true
To listen to the event yourself (e.g., to also send a push notification):
// In EventServiceProvider protected $listen = [ \Joe404\LaravelAuth\Events\SuspiciousLoginDetected::class => [ \App\Listeners\SendPushNotificationOnNewDevice::class, ], ];
Event payload:
$event->user // the authenticated user model $event->ipAddress // string $event->browser // string|null $event->os // string|null $event->city // string|null $event->country // string|null
Real-time Verification (Reverb)
When AUTH_REVERB_ENABLED=true and laravel/reverb is installed, registration verification triggers a real-time broadcast.
Setup:
AUTH_REVERB_ENABLED=true REVERB_APP_ID=your-reverb-app-id REVERB_APP_KEY=your-reverb-app-key REVERB_APP_SECRET=your-reverb-app-secret
The auth:install command appends a channel auth stub to routes/channels.php. You must open the /broadcasting/auth route to unauthenticated requests (add it before the auth:sanctum middleware group):
// bootstrap/app.php or routes/api.php Route::post('/broadcasting/auth', function (Request $request) { return Broadcast::auth($request); })->withoutMiddleware('auth:sanctum');
Frontend flow:
// 1. Register → receive temp_token const { temp_token } = response.data; // 2. Subscribe to the private channel const channel = Echo.private(`auth.verification.${temp_token}`); // 3. Listen for verification channel.listen('EmailVerified', (event) => { const { token } = event; // Sanctum token // Redirect to app, store token });
Broadcast payload:
{
"verified": true,
"token": "1|sanctum_token_here",
"redirect": "/dashboard"
}
Device & Session Tracking
Every login creates an auth_sessions_extended record. The library detects device info from two sources:
Web browsers: Parsed from the User-Agent header using jenssegers/agent.
Mobile / API clients: Send a X-Device-Info JSON header:
{
"model": "SM-G991B",
"platform": "android",
"os_version": "14"
}
The library maps the model code against resources/devices.json (~500 entries) to a marketing name. Unknown model codes are stored as-is.
Geo-location (city, country) is fetched from ip-api.com using the request IP. Private/local IPs are skipped. The lookup has a 3-second timeout and fails silently.
The session record stored:
| Column | Description |
|---|---|
platform |
web / mobile / api |
browser |
Chrome, Firefox, etc. |
os |
Windows, iOS, Android, etc. |
device_model |
Raw model code |
device_marketing_name |
Human name from devices.json |
ip_address |
Client IP |
country |
From geo lookup |
city |
From geo lookup |
last_active_at |
Updated on every authenticated request |
Admin API Token Management
Admin users (super-admin or admin role) have access to additional endpoints under /auth/admin:
| Method | Endpoint | Description |
|---|---|---|
GET |
/auth/admin/api-tokens |
List all tokens across all users |
POST |
/auth/admin/api-tokens |
Create a token not tied to any user |
PATCH |
/auth/admin/api-tokens/{id} |
Update abilities or expiry |
DELETE |
/auth/admin/api-tokens/{id} |
Revoke any token |
Admin update request body:
| Field | Type | Required | Description |
|---|---|---|---|
abilities |
array | No | Replaces the token's current abilities |
expires_in_days |
integer|null | No | null clears expiry (never expires) |
Role Assignment
The library integrates with spatie/laravel-permission. The default role (AUTH_DEFAULT_ROLE) is assigned automatically on:
- Email verification (OTP or magic link)
- Google OAuth new user creation
Your user model must use the HasRoles trait from Spatie:
use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { use HasRoles; }
To protect your own routes by role, use Spatie's built-in middleware:
Route::middleware('role:admin')->group(function () { ... }); Route::middleware('permission:edit-posts')->group(function () { ... });
Events Reference
All events are in the Joe404\LaravelAuth\Events namespace.
| Event | When fired | Key payload |
|---|---|---|
UserRegistered |
After POST /auth/register initiates |
$user_email, $password (hashed) |
EmailVerified |
After OTP or magic link verification completes | $user, $tempToken, $sanctumToken |
UserLoggedIn |
After successful login | $user, $request |
UserLoggedOut |
After logout or logout-all | — |
PasswordChanged |
After password reset or change | $user |
SuspiciousLoginDetected |
Login from unrecognised device | $user, $ipAddress, $browser, $os, $city, $country |
Listen to any event in your EventServiceProvider:
protected $listen = [ \Joe404\LaravelAuth\Events\UserLoggedIn::class => [ \App\Listeners\LogUserActivity::class, ], \Joe404\LaravelAuth\Events\PasswordChanged::class => [ \App\Listeners\NotifyPasswordChange::class, ], ];
Scheduled Jobs
The library registers two maintenance jobs automatically via AuthServiceProvider. They run on the queue named AUTH_QUEUE_NAME (default: auth-maintenance).
| Job | Schedule | What it does |
|---|---|---|
CleanExpiredOtpRecords |
Every 5 minutes | Deletes expired and used rows from auth_otp_codes |
CleanExpiredApiTokens |
Every hour | Marks or deletes API tokens past their expires_at date |
Make sure your queue worker is running:
php artisan queue:work --queue=auth-maintenance,default
Or with Horizon:
php artisan horizon
Environment Variable Quick Reference
# Core AUTH_MODE=both # api | web | both AUTH_REQUIRE_VERIFICATION=true # Verification AUTH_VERIFICATION_METHOD=both # otp | magic_link | both AUTH_OTP_LENGTH=6 AUTH_OTP_EXPIRY=10 # minutes AUTH_MAGIC_EXPIRY=30 # minutes # Tokens AUTH_TOKEN_EXPIRY=10080 # minutes (default = 7 days) # Password AUTH_PASSWORD_MIN=8 AUTH_PASSWORD_UPPERCASE=false AUTH_PASSWORD_NUMBER=false AUTH_PASSWORD_SPECIAL=false AUTH_PENDING_TTL=60 # minutes # Rate Limiting (format: "max:decay_minutes") AUTH_RATE_REGISTER=5:1 AUTH_RATE_LOGIN=5:1 AUTH_RATE_OTP_SEND=3:1 AUTH_RATE_PASSWORD_RESET=3:1 # Security AUTH_NOTIFY_NEW_DEVICE=true AUTH_LOCKOUT_ENABLED=true AUTH_LOCKOUT_MAX=10 AUTH_LOCKOUT_DECAY=15 # minutes # Roles AUTH_DEFAULT_ROLE=user # OTP channel AUTH_OTP_CHANNEL=email # email | FQCN # Google OAuth AUTH_GOOGLE_ENABLED=false GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI= # Reverb AUTH_REVERB_ENABLED=false # Queue AUTH_QUEUE_CONNECTION=redis AUTH_QUEUE_NAME=auth-maintenance # Response formatter AUTH_RESPONSE_FORMATTER= # FQCN or empty
Extending the Library
Custom user model
The library reads auth.providers.users.model from your Laravel config. As long as your user model extends Illuminate\Foundation\Auth\User, everything works.
Custom OTP channel
Implement Joe404\LaravelAuth\Contracts\OtpChannelContract and set AUTH_OTP_CHANNEL to your FQCN.
Custom response formatter
Implement Joe404\LaravelAuth\Contracts\ResponseFormatterContract and set AUTH_RESPONSE_FORMATTER or bind it in your service provider.
Listening to events
Register listeners in your EventServiceProvider as shown in the Events Reference.
Adding abilities to API tokens
When creating a token, pass any string as an ability:
{
"name": "Order service",
"abilities": ["orders:read", "orders:create", "inventory:read"]
}
Protect your routes:
Route::middleware('auth.api-token:orders:read')->get('/orders', ...);
Wildcard ["*"] grants all abilities.