DEV Community

Cover image for I Built 6 Authentication Methods for My Laravel SaaS - Here's the Full Architecture
Dmitry Isaenko
Dmitry Isaenko

Posted on

I Built 6 Authentication Methods for My Laravel SaaS - Here's the Full Architecture

Every SaaS project starts with authentication. And every time, developers face the same choice: use a starter kit and outgrow it in a month, or build from scratch and spend weeks on something that isn't your core product.

I've been building Kohana.io - a SaaS CRM/ERP for small businesses - and I got tired of this cycle. So I built a comprehensive authentication module that handles everything from basic email login to QR code authentication and real-time admin security alerts.

Now I'm extracting it into LaraFoundry - an open-source SaaS framework for Laravel - so nobody has to build this again.

Here's how it all works.


The Authentication Stack

Method Use Case
Email/Password Standard login with rate limiting
OAuth (Google, Facebook, Twitter) One-click social login
QR Code Cross-device login (scan from phone)
PIN Code Session lock for shared workstations
2FA (TOTP) Admin account protection
IP Whitelisting Admin access restriction

Tech stack: Laravel 12, Inertia.js v2, Vue 3, Custom SCSS

Key packages: Laravel Socialite v5, PragmaRX Google2FA, SimpleSoftwareIO QrCode, Jenssegers Agent


1. Email/Password Authentication

The foundation. But with important production-grade additions.

Rate Limiting

Login attempts are limited to 5 per email+IP combination:

// LoginRequest.php
public function ensureIsNotRateLimited(): void
{
    if (!RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
        return;
    }

    event(new Lockout($this));

    $seconds = RateLimiter::availableIn($this->throttleKey());

    throw ValidationException::withMessages([
        'email' => trans('auth.throttle', [
            'seconds' => $seconds,
            'minutes' => ceil($seconds / 60),
        ]),
    ]);
}

public function throttleKey(): string
{
    return Str::transliterate(
        Str::lower($this->string('email')) . '|' . $this->ip()
    );
}
Enter fullscreen mode Exit fullscreen mode

Session Tracking

Every successful login creates a detailed session record:

$request->user()->userSessions()->create([
    'session_id' => $request->session()->getId(),
    'ip_address' => $request->getClientIp(),
    'user_agent' => $request->userAgent(),
    'last_activity' => now(),
    'pin_locked' => false,
    'user_device_type' => self::getUserDeviceType(),
    'user_device_name' => Agent::device(),
    'user_os' => Agent::platform(),
    'user_browser' => Agent::browser(),
]);
Enter fullscreen mode Exit fullscreen mode

This gives users a "Active Sessions" view where they can see all their logged-in devices and clear the ones they don't recognize - similar to what Google and GitHub offer.

Remember Me

Works for both email and OAuth login. For OAuth, the remember me preference is stored in the session before the redirect and applied after the callback:

// Before OAuth redirect
if ($request->boolean('oauth_remeber_me')) {
    session(['oauth_remember_me' => true]);
}

// After OAuth callback
$remember = session('oauth_remember_me', false);
Auth::login($user, $remember);
Enter fullscreen mode Exit fullscreen mode

2. OAuth Authentication

Three providers out of the box: Google, Facebook, and Twitter. Powered by Laravel Socialite v5.

public function callback(string $provider, UserLogoService $userLogo)
{
    if (!in_array($provider, ['google', 'facebook', 'twitter'])) {
        return redirect()->route('guest_index')
            ->with('message-error', 'Invalid OAuth provider');
    }

    $socialUser = Socialite::driver($provider)->user();

    $user = User::updateOrCreate(
        ['email' => $socialUser->email],
        [
            'provider_id' => $socialUser->id,
            'provider_name' => $provider,
            'name' => $socialUser->name,
            'email_verified_at' => now(), // OAuth = auto-verified
            'provider_token' => $socialUser->token,
            'provider_refresh_token' => $socialUser->refreshToken,
            'last_login_at' => now(),
            'last_activity_at' => now(),
        ]
    );

    Auth::login($user, $remember);
}
Enter fullscreen mode Exit fullscreen mode

Key design decisions:

  • Email as the unique identifier - if a user registers via email and later logs in with Google using the same email, the account is linked automatically
  • OAuth emails are auto-verified - no need to send a verification email when Google already confirmed it
  • Provider tokens are stored - useful for future integrations (e.g., importing contacts from Google)
  • Login method is tracked per session - so the "Active Sessions" view shows whether the user logged in natively or via OAuth

3. QR Code Authentication

This is the feature I'm most proud of. It works like WhatsApp Web: scan a QR code from your already-authenticated phone to log in on desktop.

The Architecture

┌──────────────────┐        ┌──────────────┐        ┌──────────────────┐
│  Desktop Browser │        │    Server    │        │  Mobile (Auth'd) │
│    (Guest)       │        │              │        │                  │
├──────────────────┤        ├──────────────┤        ├──────────────────┤
│ 1. Request QR    │───────>│ 2. Generate  │        │                  │
│                  │<───────│    UUID +    │        │                  │
│ 3. Display QR    │        │    SignIn    │        │                  │
│                  │        │    Request   │        │                  │
│ 4. Poll every 3s │───────>│              │        │                  │
│                  │        │              │<───────│ 5. Scan QR code  │
│                  │        │ 6. Verify +  │───────>│ 7. Show "Success"│
│                  │        │    Approve   │        │                  │
│ 8. Poll detects  │<───────│              │        │                  │
│    approval      │        │              │        │                  │
│ 9. Auto-login    │        │              │        │                  │
└──────────────────┘        └──────────────┘        └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

Token Generation

public function codeGenerate()
{
    $token = Uuid::uuid4();
    session()->put('token', Crypt::encrypt($token));

    $signInRequest = SignInRequest::create([
        'token' => $token,
        'expires_at' => now()->addMinutes(5),
        'ip_address' => request()->ip(),
        'user_agent' => request()->userAgent(),
    ]);

    session()->put('signInId', Crypt::encrypt($signInRequest->id));

    $qrCodeUrl = route('qr.verifyLogin', [
        'id' => Crypt::encrypt($signInRequest->id),
        'token' => $token,
    ]);

    $qrCode = base64_encode(
        QrCode::size(400)->format('svg')->generate($qrCodeUrl)
    );

    return response()->json([
        'qrCode' => $qrCode,
        'activeUntil' => $signInRequest->expires_at,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Approval from Authenticated Device

public function verifyLogin(string $id, string $token)
{
    // Admins cannot approve QR logins (security)
    if (auth()->user()->is_admin) {
        return response()->json([
            'error' => 'Admin QR login is forbidden.'
        ], 404);
    }

    $signInRequest = SignInRequest::query()
        ->where('expires_at', '>', now())
        ->where('id', Crypt::decrypt($id))
        ->where('token', $token)
        ->first();

    if (!$signInRequest) {
        return response()->json(['error' => 'Code incorrect!'], 404);
    }

    $signInRequest->update([
        'user_id' => auth()->id(),
        'approved' => true,
        'approved_at' => now(),
        'approved_ip' => request()->ip(),
        'approved_user_agent' => request()->userAgent(),
    ]);

    return response()->json(['message' => 'Code recognized'], 200);
}
Enter fullscreen mode Exit fullscreen mode

Browser Polling

public function pollLogin()
{
    $signInRequest = SignInRequest::query()
        ->where('id', Crypt::decrypt(session('signInId')))
        ->where('token', Crypt::decrypt(session('token')))
        ->where('expires_at', '>', now())
        ->first();

    if ($signInRequest && $signInRequest->approved) {
        Auth::login(User::find($signInRequest->user_id));
        $request->session()->regenerate();
        // Create session record...
        return response()->json(['result' => true]);
    }

    return response()->json(['result' => false]);
}
Enter fullscreen mode Exit fullscreen mode

Security measures:

  • Tokens expire in 5 minutes
  • Request IDs are encrypted - never exposed in URLs raw
  • Admins cannot approve QR logins (prevents privilege escalation)
  • Both requesting and approving devices are fingerprinted
  • Session is regenerated after login

4. PIN Code Screen Lock

This feature was born from a real need. In Kohana.io, warehouse workers and sales managers often use shared computers. Logging out and back in every break is painful. Session timeout destroys unsaved work.

How It Works

Users enable a 4-digit PIN from their profile:

public function enable(Request $request)
{
    $request->validate(['pin' => 'required|digits:4']);
    $request->user()->update([
        'pin_code' => bcrypt($request->pin)
    ]);
}
Enter fullscreen mode Exit fullscreen mode

The CheckPinLockMiddleware runs on every request:

public function handle(Request $request, Closure $next)
{
    $user = $request->user();

    if (!$user || !$user->pin_code) {
        return $next($request);
    }

    $session = $user->userSessions()
        ->where('session_id', $request->session()->getId())
        ->first();

    // Already locked in DB?
    if ($session && $session->pin_locked) {
        return redirect()->route('pin.enter');
    }

    // Inactivity timeout check
    $timeout = config('security.pin_lock_timeout', 1800);
    if ($session && $session->last_activity
            ->diffInSeconds(now()) > $timeout) {
        $session->update(['pin_locked' => true]);
        return redirect()->route('pin.enter');
    }

    $session?->update(['last_activity' => now()]);

    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

Why This Is Better Than Session Timeout

Feature Session Timeout PIN Lock
Unlock speed Full password + possible 2FA 4 digits
Session data Lost Preserved
Unsaved work Gone Safe
Per-device control No Yes
Bypass via background requests Easy Impossible (DB-persisted)

The lock state is stored in the database, not the session. This means background API calls or WebSocket connections can't accidentally reset the inactivity timer.


5. Admin Security - The 3-Layer System

Layer 1: IP Whitelisting

private function checkAdminByIP(): bool
{
    if (empty(config('own.admin_ips'))) {
        return true;
    }

    $allowedIps = explode(',', config('own.admin_ips'));
    return in_array(request()->ip(), $allowedIps);
}
Enter fullscreen mode Exit fullscreen mode

Wrong IP? Instant force-logout. No error page. The GetVisitorStatusAction returns 'forcelogout' and the controller handles it:

if (($this->getVisitorStatus)() === 'forcelogout') {
    return app(AuthenticatedSessionController::class)
        ->destroy(request());
}
Enter fullscreen mode Exit fullscreen mode

Layer 2: Google Authenticator 2FA

After successful email+password login, admins see a 2FA screen. The Require2FA middleware enforces this on all admin routes:

public function handle(Request $request, Closure $next)
{
    if (($this->getVisitorStatus)() === 'admin') {
        if (!session('2fa_verified')) {
            if (!$request->routeIs('admin.2fa.*')) {
                return Inertia::location(route('admin.2fa.show'));
            }
        }
    }

    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

Layer 3: Real-Time Notifications

Every failed admin login attempt triggers an email AND Telegram notification:

public static function notifyAdminOnLoginFail($step = 'During login input')
{
    Notification::route('mail', config('own.admin_email'))
        ->route('telegram', config('own.telegram_chat_id'))
        ->notify(new AdminLoginAttemptNotification($step));
}
Enter fullscreen mode Exit fullscreen mode

The notification includes: IP + geolocation, device fingerprint, user agent, and which step failed (login, 2FA, or IP).

Trigger points:

  1. Wrong password with admin email
  2. Wrong 2FA code
  3. Correct credentials but unauthorized IP

6. The Visitor Status System

The GetVisitorStatusAction is the central piece that ties everything together:

public function __invoke(): string
{
    if (!auth()->check()) return 'guest';

    $user = auth()->user();

    if ($user->is_admin && $this->checkAdminByEmail()) {
        if ($this->checkAdminByIP()) {
            return 'admin';
        } else {
            AdminHelper::notifyAdminOnLoginFail('IPs denied');
            return 'forcelogout';
        }
    }

    if ($user->user_blocked_at) return 'authBlocked';
    if ($user->user_deleted_at) return 'authDeleted';

    return 'auth';
}
Enter fullscreen mode Exit fullscreen mode

Six possible states. Used in middleware, controllers, and frontend via Inertia shared data. Every component in the system knows exactly who the current user is and what they can do.


Route Architecture

// Guest routes
Route::middleware('guest')->group(function () {
    Route::post('register', [RegisteredUserController::class, 'store']);
    Route::post('login', [AuthenticatedSessionController::class, 'store']);

    // OAuth
    Route::controller(OAuthLoginController::class)
        ->prefix('oauth')->name('oauth.')->group(function () {
            Route::get('{provider}/callback', 'callback');
            Route::get('{provider}', 'redirect');
        });

    // Password reset
    Route::controller(ResetPasswordController::class)
        ->prefix('password')->name('password.')->group(function () {
            Route::post('forgot-password', 'sendEmail');
            Route::get('reset-password/{token}', 'resetForm');
            Route::post('reset-password', 'resetHandler');
        });
});

// QR Code (mixed middleware)
Route::controller(LoginWithQrCode::class)
    ->prefix('qr')->name('qr.')->group(function () {
        Route::post('code-generate', 'codeGenerate')->middleware('guest');
        Route::post('poll-login', 'pollLogin')->middleware('guest');
        Route::get('verify/{id}/{token}', 'verifyLogin')
            ->middleware(['auth', 'prevent.nonadmin.routes']);
    });

// Authenticated routes
Route::middleware(['auth'])->group(function () {
    // Email verification
    Route::controller(EmailVerificationController::class)
        ->middleware('prevent.nonadmin.routes')
        ->prefix('email')->name('verification.')->group(function () {
            Route::get('verify-email', 'notice');
            Route::get('verify-email/{id}/{hash}', 'verify')
                ->middleware(['signed', 'throttle:6,1']);
            Route::post('email/verification-notification', 'send')
                ->middleware('throttle:6,1');
        });

    // PIN code
    Route::controller(PinController::class)
        ->prefix('pin')->name('pin.')->group(function () {
            Route::get('/', fn () => inertia('auth/EnterPin'));
            Route::post('check', 'check');
            Route::post('enable', 'enable');
            Route::post('disable', 'disable');
            Route::post('lock', 'lock');
        });
});

// Admin routes (2FA + IP protected)
Route::middleware([AdminAccess::class, '2fa'])->group(function () {
    Route::prefix('admin/2fa')->name('admin.2fa.')
        ->controller(TwoFactorController::class)->group(function () {
            Route::get('/', 'show');
            Route::post('/', 'verify');
        });
});
Enter fullscreen mode Exit fullscreen mode

7. Testing - Proof It All Works

Authentication is the front door to your SaaS. If it breaks, nobody gets in. If it has a hole, everyone gets in. Automated tests are non-negotiable.

PIN lock testing:

it('persists pin_locked=true to DB when auto-lock fires due to inactivity', function () {
    $user = User::factory()->create(['pin_code' => bcrypt('1234')]);
    $sessionId = actingAsWithSession($user);

    UserSession::where('session_id', $sessionId)
        ->update(['last_activity' => now()->subMinutes(31)->utc()]);

    $this->withCookie($cookieName, $sessionId)->get(route('home'));

    $session = UserSession::where('session_id', $sessionId)->first();
    expect((bool) $session->pin_locked)->toBeTrue();
});

it('keeps lock active when last_activity is reset while pin_locked=true', function () {
    // This test caught a real bug:
    // a background AJAX request was resetting last_activity,
    // which accidentally unlocked PIN-locked screens
    UserSession::where('session_id', $sessionId)
        ->update(['pin_locked' => true, 'last_activity' => now()->subMinutes(31)]);

    // Background request resets last_activity
    UserSession::where('session_id', $sessionId)
        ->update(['last_activity' => now()]);

    $response = $this->withCookie($cookieName, $sessionId)->get(route('home'));
    $response->assertRedirect(route('pin.enter')); // still locked!
});
Enter fullscreen mode Exit fullscreen mode

Ban system testing:

it('blocks banned user from accessing main routes', function () {
    $this->user->update(['user_blocked_at' => now()]);
    $response = $this->withCookie($cookieName, $sessionId)->get(route('home'));
    $response->assertRedirect(route('user.blocked'));
});

it('allows banned user to access tickets (support)', function () {
    $this->user->update(['user_blocked_at' => now()]);
    $response = $this->withCookie($cookieName, $sessionId)->get(route('tickets.index'));
    $response->assertSuccessful();
});

it('blocks entire company when owner is banned', function () {
    // When the owner gets banned, ALL employees in that company lose access
});
Enter fullscreen mode Exit fullscreen mode

Blocked user page:

it('blocked user can view user.blocked page', function () {
    $this->user->update([
        'user_blocked_at' => now(),
        'user_blocked_status' => 1,
    ]);

    $response->assertInertia(fn ($page) => $page
        ->component('auth/UserBlocked')
        ->has('blockReason')
        ->has('blockDate')
    );
});

it('redirects previously blocked user after unblocking', function () {
    $this->user->update(['user_blocked_at' => now()]);
    $this->user->update(['user_blocked_at' => null]);

    $response->assertRedirect(); // goes to first allowed route
});
Enter fullscreen mode Exit fullscreen mode

What the test suite covers:

  • Login/logout flow (valid + invalid credentials)
  • PIN auto-lock (inactivity timeout, DB persistence, correct/wrong PIN, edge cases)
  • PIN manual lock (lock, unlock, wrong PIN)
  • User ban (blocking, allowed routes, support ticket access, logout)
  • Company owner ban (cascade to all employees)
  • Session management (isolation, ordering by last_activity)
  • Blocked user page (display, unblock redirect)
  • CheckAccess middleware priority (user ban > owner ban > payment check)
  • Email verification (valid hash, invalid hash, signed URLs)

What's Next

This authentication module ships with LaraFoundry out of the box. No configuration beyond your .env file.

Coming up next in the LaraFoundry series:

  • Roles & Permissions - Spatie + custom gates
  • Team Management - multi-company, invitations, member roles
  • Subscription Billing - Stripe integration

Star the repo on GitHub or join the waitlist at larafoundry.com to get notified.


LaraFoundry is being extracted from Kohana.io - a production SaaS CRM/ERP. Every feature described in this article is running in production today.

larafoundry #laravel #php #saas #authentication #webdev #security #opensource

Top comments (0)