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.
A complete referral system with built-in anti-abuse detection. The package generates a unique code per user, lets new users submit a referrer's code at registration (or up to N hours after), runs fingerprint-based abuse checks, and hands the validated referral to your reward logic.
This feature is security-sensitive. Read the What is detected / what is NOT section before launching — there are real-world bypass scenarios you should understand.
User A registers ─────► gets referral_code "K9PF2LMX4A"
│
│ (User A shares code)
▼
User B registers with referral_code = "K9PF2LMX4A"
│
▼
┌─── Package compares B's fingerprint vs A's ───┐
│ │
▼ ▼
Different device & IP Same device or IP
│ │
▼ ▼
status = valid status = blocked / suspicious
│ │
▼ ▼
reward_handler fires NO reward — fires
│ SuspiciousReferralDetected event
▼
ReferralRedeemed event
Key properties:
block, flag, ignore).The package writes the generated referral code into a column on your users table. Add it via a migration in your app:
Schema::table('users', function (Blueprint $table) {
$table->string('referral_code', 32)->nullable()->unique()->after('email');
});
Make sure referral_code is in your User model's $fillable:
protected $fillable = [
'name', 'email', 'password', 'referral_code',
];
php artisan migrate
This creates the referrals table and adds fingerprint_hash to auth_sessions_extended.
AUTH_REFERRAL_CODE_ENABLED=true
Or in config/auth_system.php:
'referral_code' => [
'enabled' => true,
// ...
],
After enabling, the package:
POST /auth/register/completePOST /auth/referrals/redeemGET /auth/referralsGET /auth/referrals/statsGET /auth/admin/referralsPATCH /auth/admin/referrals/{id}POST /auth/register
{
"email": "bob@example.com",
"referral_code": "K9PF2LMX4A" ← optional
}
The code is held in cache alongside the rest of Bob's registration. After Bob verifies his email and sets a password (/auth/register/complete), the package:
K9PF2LMX4A (User A)valid / suspicious / blocked)referrals rowReferralCreatedvalid → calls your reward_handler → fires ReferralRedeemedsuspicious / blocked → fires SuspiciousReferralDetectedThe registration response includes a referral_error field if any of the hard rules tripped (code not found, self-referral, already redeemed). Registration itself still returns 201.
If Bob forgot to enter a code during registration, he can still submit one within the configured window (default: 2 hours from account creation):
POST /auth/referrals/redeem (authenticated)
{
"referral_code": "K9PF2LMX4A"
}
The same abuse checks run. Outside the window the endpoint returns a clear error:
{
"success": false,
"message": "Referral code can no longer be redeemed. The redemption window has passed."
}
This is the most important section in this doc. Do not skip it.
| Scenario | Why it's caught |
|---|---|
| User A uses their own code in the same browser | Hard rule: own code rejected |
| User A uses their own code with a different email, same browser, same device | Fingerprint hash matches (canvas/WebGL/screen) |
| Same device, different browser (e.g. Chrome → Firefox on same laptop) | Fingerprint hash matches (the JS snippet excludes browser strings on purpose) |
| Same device, same browser, incognito mode | Canvas/WebGL still come from the same GPU |
| Same device, VPN changing the IP | on_same_device rule still matches |
| Same home Wi-Fi, two different family members on different laptops | on_same_ip rule (default: flag — manual admin review) |
| Mobile app deleted and reinstalled on iOS with Keychain storage | device_id survives uninstall |
Mobile app deleted and reinstalled on Android using ANDROID_ID |
ANDROID_ID survives uninstall |
| User A logged into account on two phones, then logged out of one and used it to self-refer | Permanent device history table (see below) — the device is matched even though the session is gone |
| User A logged into account on Mobile A then Mobile B, then on Mobile B created a self-referral attempt | Both devices are in User A's permanent history — Mobile B is matched regardless of which one was last active |
| Scenario | Why it's missed | Mitigation |
|---|---|---|
| Different physical device, different network, different email | No shared signal at all | Email/phone verification reduces volume; rate-limit registration |
| Factory reset of iOS/Android phone | New ANDROID_ID, new Keychain | Combine with phone verification |
| Same person on phone + laptop with mobile data | Different fingerprint, different IP | Limit reward to one per "household" via your own logic |
| User disables JavaScript in browser | No fingerprint hash sent — falls back to IP-only | The package documents this — you can choose to require the JS fingerprint via a custom check |
| Sophisticated attacker spoofing canvas/WebGL with a browser extension | The hash will differ | Out of scope for this package — use a commercial anti-fraud SDK if you need device-graph defenses |
No fingerprint system can catch the last few rows. If your reward is large enough to attract sophisticated abuse, add:
The package keeps a permanent device history table (auth_user_devices) — one row per (user, device) — that is populated on every login and never deleted on logout.
When a new user submits a referral code, the abuse check asks:
"Does the referrer have any historical device that matches this new user's fingerprint or IP?"
Not "their last device" — any device, ever. That closes this otherwise-clean bypass:
AAA) and Mobile B (BBB) — both phones become part of A's history.Without the permanent history, the package would only see active sessions — Mobile B was just logged out, so no signal. With the history table, Mobile B is still there with last_seen_at = yesterday, the new account's fingerprint matches BBB, and the referral is blocked.
The user can see and manage this history at GET /auth/devices (see section 11). The DELETE /auth/devices/{id} endpoint lets a user forget a device — at which point that device is no longer in their history and could be used to submit a new referral. This is a deliberate trade-off: the user owns their data.
The package detects three signals independently:
| Signal | Default Action | What it means |
|---|---|---|
on_same_ip |
flag |
New user has the same IP as the referrer (same Wi-Fi). Common false positives — same family, office, café. |
on_same_device |
block |
Fingerprint hash matches the referrer's, but IP differs (VPN). Very strong abuse signal. |
on_same_ip_and_device |
block |
Both match. Almost certainly the same person. |
Three actions:
block → status set to blocked, no reward, SuspiciousReferralDetected firesflag → status set to suspicious, no reward, SuspiciousReferralDetected fires (you can admin-override later)ignore → status set to valid, reward fires normallyOverride in .env:
AUTH_REFERRAL_ABUSE_SAME_IP=flag
AUTH_REFERRAL_ABUSE_SAME_DEVICE=block
AUTH_REFERRAL_ABUSE_BOTH=block
flag / flag / blockflag / block / blockblock / block / blockFor the device-level fingerprint to actually catch abuse, your frontend must send the X-Browser-Fingerprint header on registration and redeem requests.
This snippet collects device-level signals (NOT browser-specific ones), hashes them with SHA-256, and returns a stable hash. It produces the same hash across Chrome/Firefox/incognito on the same machine.
// fingerprint.js
async function computeBrowserFingerprint() {
const signals = [];
// 1. Canvas — rendered by the GPU. Consistent across browsers on the same device.
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(0, 0, 200, 50);
ctx.fillStyle = '#069';
ctx.fillText('fingerprint-canvas-anchor', 2, 2);
signals.push(canvas.toDataURL());
} catch (_) {}
// 2. WebGL renderer — the actual GPU name string.
try {
const gl = document.createElement('canvas').getContext('webgl');
const ext = gl.getExtension('WEBGL_debug_renderer_info');
signals.push(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL));
signals.push(gl.getParameter(ext.UNMASKED_VENDOR_WEBGL));
} catch (_) {}
// 3. Screen + colour depth + pixel ratio.
signals.push(`${screen.width}x${screen.height}x${screen.colorDepth}@${window.devicePixelRatio}`);
// 4. Timezone.
signals.push(Intl.DateTimeFormat().resolvedOptions().timeZone);
// 5. Hardware.
signals.push(navigator.hardwareConcurrency || '');
signals.push(navigator.deviceMemory || '');
signals.push(navigator.maxTouchPoints || 0);
// 6. Audio context fingerprint.
try {
const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
const ctx = new AC(1, 5000, 44100);
const osc = ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.value = 10000;
const comp = ctx.createDynamicsCompressor();
osc.connect(comp).connect(ctx.destination);
osc.start(0);
const buf = await ctx.startRendering();
signals.push(buf.getChannelData(0).slice(4500, 5000).reduce((a, b) => a + Math.abs(b), 0).toString());
} catch (_) {}
// SHA-256 hash of the concatenated signals.
const enc = new TextEncoder().encode(signals.join('|'));
const hash = await crypto.subtle.digest('SHA-256', enc);
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
export { computeBrowserFingerprint };
Compute once at app boot (cache the result) and attach it to every auth request:
// axios example
import axios from 'a
xios';
import { computeBrowserFingerprint } from './fingerprint';
const fp = await computeBrowserFingerprint();
axios.defaults.headers.common['X-Browser-Fingerprint'] = fp;
// fetch example
const res = await fetch('/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Browser-Fingerprint': fp,
},
body: JSON.stringify({ email, referral_code: code }),
});
The package degrades to IP-only matching. Referrals from a frontend without the JS snippet still work, but you lose the device-level abuse signal. No errors, no warnings — silent degradation by design.
You can change the header name via config:
AUTH_REFERRAL_FP_HEADER=X-My-Fingerprint
Mobile apps already send the X-Device-Info JSON header. Add a device_id field to it:
{
"model": "SM-G991B",
"platform": "android",
"device_id": "550e8400-e29b-41d4-a716-446655440000"
}
Generate a UUID once, store it in the Keychain (NOT UserDefaults). Keychain entries survive app uninstall so a malicious user cannot delete + reinstall to get a fresh ID.
import Security
import Foundation
enum DeviceID {
private static let service = "com.yourapp.deviceid"
private static let account = "device-id"
static func get() -> String {
if let existing = read() { return existing }
let new = UUID().uuidString
save(new)
return new
}
private static func read() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let s = String(data: data, encoding: .utf8) else { return nil }
return s
}
private static func save(_ value: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: value.data(using: .utf8)!,
]
SecItemAdd(query as CFDictionary, nil)
}
}
Settings.Secure.ANDROID_ID is a 64-bit hex string tied to the device + your app's signing key. It's stable across reinstalls and only changes on factory reset.
import android.content.Context
import android.provider.Settings
object DeviceId {
fun get(context: Context): String =
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
}
Then in your network layer (Retrofit/Ktor/OkHttp):
val deviceInfo = mapOf(
"model" to Build.MODEL,
"platform" to "android",
"device_id" to DeviceId.get(context),
)
request.addHeader("X-Device-Info", Gson().toJson(deviceInfo))
Same as the browser case — the package degrades to IP-only matching. No error.
The package does not know what "reward" means in your app. You wire it up by implementing one method in one class.
app/Auth/MyReferralReward.php:
<?php
declare(strict_types=1);
namespace App\Auth;
use Joe404\LaravelAuth\Contracts\ReferralRewardHandlerContract;
use Joe404\LaravelAuth\Models\Referral;
class MyReferralReward implements ReferralRewardHandlerContract
{
public function handle(Referral $referral): void
{
$referrer = $referral->referrer;
$referred = $referral->referred;
// Your reward logic goes here.
// (See examples below.)
}
}
AUTH_REFERRAL_REWARD_HANDLER=App\Auth\MyReferralReward
That's it. The package will call handle($referral) exactly once, the moment the referral becomes valid — whether that happens at registration, via the redeem endpoint, or via an admin override.
When your handler runs:
$referral->status is valid$referral->redeemed_at is still null (the package sets it to now() after your handler returns)$referral->referrer and $referral->referred are eager-loadedIf your handler throws:
pendingThis means if your reward depends on an external API (Stripe, mailer, etc.) that times out, the referral is left in a pending state. You can listen to ReferralCreated from a queued listener and retry from there:
class RetryReferralReward implements ShouldQueue
{
public function handle(ReferralCreated $event): void
{
if ($event->referral->status === 'pending') {
app(\App\Auth\MyReferralReward::class)->handle($event->referral);
}
}
}
class CreditWalletReward implements ReferralRewardHandlerContract
{
public function __construct(
private readonly WalletService $wallets,
) {}
public function handle(Referral $referral): void
{
$this->wallets->credit(
user: $referral->referrer,
amount: 100_00, // cents
reason: "Referral reward — invited {$referral->referred->email}",
);
}
}
class FreeSubscriptionMonthReward implements ReferralRewardHandlerContract
{
public function handle(Referral $referral): void
{
$subscription = $referral->referrer->subscription('default');
if ($subscription === null || $subscription->ended()) {
// Referrer has no active subscription — skip
return;
}
$subscription->extendTrial(now()->addMonth());
}
}
class DiscountCouponReward implements ReferralRewardHandlerContract
{
public function handle(Referral $referral): void
{
$coupon = Coupon::create([
'user_id' => $referral->referrer->id,
'code' => 'REF-' . strtoupper(Str::random(8)),
'discount_pct' => 20,
'expires_at' => now()->addDays(30),
'usage_limit' => 1,
'source_referral_id' => $referral->id,
]);
Mail::to($referral->referrer)->send(new ReferralCouponMail($coupon));
}
}
Leave AUTH_REFERRAL_REWARD_HANDLER unset. Wire up a listener:
// app/Providers/EventServiceProvider.php
protected $listen = [
\Joe404\LaravelAuth\Events\ReferralCreated::class => [
\App\Listeners\HandleReferral::class,
],
];
class HandleReferral implements ShouldQueue
{
public function handle(ReferralCreated $event): void
{
if ($event->referral->status !== 'valid') return;
// your reward logic, on a queue, decoupled from the request
}
}
| Method | URL | Auth | Purpose |
|---|---|---|---|
POST |
/auth/register |
none | Optional referral_code field accepted |
POST |
/auth/referrals/redeem |
sanctum | Submit a code after registration (within window) |
GET |
/auth/referrals |
sanctum | List user's own referrals + status |
GET |
/auth/referrals/stats |
sanctum | Aggregate counts for user |
GET |
/auth/admin/referrals |
sanctum + admin | Paginated all-referrals list |
PATCH |
`/auth... |
How can I help you explore Laravel packages today?