LaravelPackages.net
Acme Inc.
Toggle sidebar
ghostcompiler/laravel-auth

Headless Laravel authentication security with TOTP 2FA, passkeys, trusted devices, recovery codes, and Socialite-powered social login helpers.

0
0
v1.0.3
About ghostcompiler/laravel-auth

ghostcompiler/laravel-auth is a Laravel package for headless laravel authentication security with totp 2fa, passkeys, trusted devices, recovery codes, and socialite-powered social login helpers.. It currently has 0 GitHub stars and 0 downloads on Packagist (latest version v1.0.3). Install it with composer require ghostcompiler/laravel-auth. Discover more Laravel packages by ghostcompiler or browse all Laravel packages to compare alternatives.

Last updated

Laravel Auth

Laravel Auth

Premium, headless Laravel authentication hardening library for TOTP 2FA, passkeys, recovery codes, multi-channel OTP, trusted devices, and social authentication.

Laravel PHP Version Security Hardened Ghost Compiler


Headless Laravel authentication hardening with:

  • TOTP 2FA
  • recovery codes
  • passkeys via WebAuthn
  • email, SMS, and WhatsApp OTP
  • trusted devices
  • Socialite-based social account linking
  • runtime tenant OAuth credentials for social login

This package does not replace your login system. It adds security layers on top of your existing auth flow.

Requirements

  • PHP 8.2+
  • Laravel 10, 11, 12, or 13
  • database access for package tables
  • HTTPS for browser passkey flows
  • laravel/socialite support for social login helpers

Installation

composer require ghostcompiler/laravel-auth
php artisan ghost:laravel-auth
php artisan migrate

Force republishing if you want to overwrite previously published files:

php artisan ghost:laravel-auth --force

What ghost:laravel-auth publishes:

  • config/laravel-auth.php
  • one package migration file
  • package OTP views to resources/views/vendor/laravel-auth
  • SMS and WhatsApp transport stubs to app/LaravelAuth

Publish only OTP assets later if needed:

php artisan laravel-auth:otp:publish

Local Package Development

To test this package from another Laravel app through a local path repository:

{
  "repositories": [
    {
      "type": "path",
      "url": "../laravel-auth",
      "options": {
        "symlink": true
      }
    }
  ],
  "require": {
    "ghostcompiler/laravel-auth": "*"
  }
}

Then in the app:

composer require ghostcompiler/laravel-auth
php artisan ghost:laravel-auth
php artisan migrate
php artisan optimize:clear

If the app does not pick up local changes automatically:

composer update ghostcompiler/laravel-auth
composer dump-autoload
php artisan optimize:clear

What The Package Adds

Middleware aliases:

  • 2fa
  • laravel-auth.2fa
  • laravel-auth.enforce
  • laravel-auth.throttle

Published config:

Main facade contract:

  • src/Contracts/LaravelAuthManager.php

Single package migration:

Database objects created:

  • user table columns:
    • laravel_auth_totp_secret
    • laravel_auth_two_factor_enabled
    • laravel_auth_confirmed_at
  • laravel_auth_recovery_codes
  • laravel_auth_trusted_devices
  • laravel_auth_passkeys
  • laravel_auth_webauthn_challenges
  • laravel_auth_social_accounts
  • laravel_auth_otp_challenges

Current Defaults

From the package config:

  • enforce_2fa is true
  • 2FA enforcement is pushed into the web middleware group
  • OTP TTL is 300 seconds
  • OTP max attempts is 5
  • rate limit decay is 60 seconds
  • TOTP uses 6 digits, 30-second period, 1-step window
  • trusted devices are bound to user agent by default
  • trusted-device IP binding is off by default
  • WhatsApp OTP is disabled by default
  • social runtime stateless mode defaults to false
use Illuminate\Support\Facades\Route;

Route::middleware(['auth', 'laravel-auth.2fa'])->group(function () {
    Route::get('/billing', fn () => 'protected');
    Route::get('/settings/security', fn () => 'security');
});

Add throttling to sensitive verification endpoints:

Route::post('/security/otp/verify', [SecurityController::class, 'verifyOtp'])
    ->middleware(['auth', 'laravel-auth.throttle:otp']);

Route::post('/security/passkey/verify', [SecurityController::class, 'verifyPasskey'])
    ->middleware(['auth', 'laravel-auth.throttle:passkey']);

TOTP 2FA Setup

Enable 2FA for a user:

$setup = LaravelAuth::enable2FA(auth()->user());

return response()->json([
    'secret' => $setup['secret'],
    'otpauth_uri' => $setup['otpauth_uri'],
]);

Confirm setup:

$result = LaravelAuth::confirmTwoFactorSetup(
    auth()->user(),
    $request->string('code')
);

return response()->json([
    'recovery_codes' => $result['recovery_codes'],
]);

Disable 2FA:

LaravelAuth::disable2FA(auth()->user());

Demo 2FA Controller

<?php

namespace App\Http\Controllers\Security;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use LaravelAuth;

class TwoFactorController extends Controller
{
    public function begin(Request $request)
    {
        $setup = LaravelAuth::enable2FA($request->user());

        return response()->json($setup);
    }

    public function confirm(Request $request)
    {
        $result = LaravelAuth::confirmTwoFactorSetup(
            $request->user(),
            $request->string('code')
        );

        return response()->json($result);
    }

    public function disable(Request $request)
    {
        LaravelAuth::disable2FA($request->user());

        return response()->json(['status' => 'disabled']);
    }
}

Verifying 2FA During Login

TOTP or recovery code:

$ok = LaravelAuth::attemptOtp(
    $user,
    $request->string('code'),
    rememberDevice: (bool) $request->boolean('remember_device'),
    deviceName: $request->string('device_name')->toString() ?: null,
);

abort_unless($ok, 422, 'Invalid code.');

You can also verify without fully marking the session:

$proof = LaravelAuth::verifyOTP($user, $request->string('code'));

Recovery Codes

Generate or regenerate recovery codes:

$codes = LaravelAuth::generateRecoveryCodes(auth()->user());

Recovery codes are consumed automatically when passed through verifyOTP() or attemptOtp().

Passkeys / WebAuthn

Begin passkey registration:

$options = LaravelAuth::registerPasskey(auth()->user(), 'MacBook Pro');

Finish registration:

$passkey = LaravelAuth::finishPasskeyRegistration(
    auth()->user(),
    $request->all(),
    'MacBook Pro'
);

Begin assertion:

$options = LaravelAuth::requestPasskeyAssertion(auth()->user());

Verify assertion only:

$proof = LaravelAuth::verifyPasskeyAssertion(auth()->user(), $request->all());

Attempt assertion and fully authenticate:

$ok = LaravelAuth::attemptPasskey(
    auth()->user(),
    $request->all(),
    rememberDevice: true,
    deviceName: 'Office Laptop'
);

OTP Channels

Email OTP

Send:

LaravelAuth::sendEmailOtp(auth()->user());

Verify only:

$proof = LaravelAuth::verifyEmailOtp(auth()->user(), $request->string('code'));

Attempt and mark session:

$ok = LaravelAuth::attemptEmailOtp(auth()->user(), $request->string('code'));

SMS OTP

Send:

LaravelAuth::sendSmsOtp(auth()->user(), '+15550001111');

Verify:

$proof = LaravelAuth::verifySmsOtp(
    auth()->user(),
    '+15550001111',
    $request->string('code')
);

Attempt:

$ok = LaravelAuth::attemptSmsOtp(
    auth()->user(),
    '+15550001111',
    $request->string('code')
);

WhatsApp OTP

Enable it first in config/laravel-auth.php or your published config.

Send:

LaravelAuth::sendWhatsAppOtp(auth()->user(), '+15550002222');

Verify:

$proof = LaravelAuth::verifyWhatsAppOtp(
    auth()->user(),
    '+15550002222',
    $request->string('code')
);

Attempt:

$ok = LaravelAuth::attemptWhatsAppOtp(
    auth()->user(),
    '+15550002222',
    $request->string('code')
);

Demo OTP Controller

<?php

namespace App\Http\Controllers\Security;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use LaravelAuth;

class OtpController extends Controller
{
    public function sendEmail(Request $request)
    {
        return response()->json(
            LaravelAuth::sendEmailOtp($request->user())
        );
    }

    public function verifyEmail(Request $request)
    {
        $ok = LaravelAuth::attemptEmailOtp(
            $request->user(),
            $request->string('code')
        );

        abort_unless($ok, 422, 'Invalid email OTP.');

        return response()->json(['status' => 'verified']);
    }

    public function sendSms(Request $request)
    {
        return response()->json(
            LaravelAuth::sendSmsOtp($request->user(), $request->string('phone'))
        );
    }

    public function verifySms(Request $request)
    {
        $ok = LaravelAuth::attemptSmsOtp(
            $request->user(),
            $request->string('phone'),
            $request->string('code')
        );

        abort_unless($ok, 422, 'Invalid SMS OTP.');

        return response()->json(['status' => 'verified']);
    }
}

Trusted Devices

Trusted devices are created automatically when you use:

  • attemptOtp(..., rememberDevice: true, ...)
  • attemptPasskey(..., rememberDevice: true, ...)

Relevant config keys:

  • trusted_devices.cookie
  • trusted_devices.ttl_days
  • trusted_devices.bind_user_agent
  • trusted_devices.bind_ip

Social Login

LaravelAuth supports two social-login modes:

  1. static/global provider config
  2. runtime provider config for tenant-specific OAuth credentials

Backward compatibility is preserved. Existing static Socialite-based setups continue to work.

Static Social Login Setup

Enable providers in your published config/laravel-auth.php.

Example:

'social' => [
    'default_stateless' => false,
    'providers' => [
        'google' => [
            'enabled' => true,
            'client_id' => env('GOOGLE_CLIENT_ID'),
            'client_secret' => env('GOOGLE_CLIENT_SECRET'),
            'redirect' => env('GOOGLE_REDIRECT_URI'),
            'scopes' => ['openid', 'profile', 'email'],
            'with' => [],
        ],
        'github' => [
            'enabled' => true,
            'client_id' => env('GITHUB_CLIENT_ID'),
            'client_secret' => env('GITHUB_CLIENT_SECRET'),
            'redirect' => env('GITHUB_REDIRECT_URI'),
            'scopes' => ['read:user', 'user:email'],
            'with' => [],
        ],
    ],
],

Discover configured providers:

$providers = LaravelAuth::socialProviders();

Redirect:

return LaravelAuth::redirectToSocialProvider('google');

Callback:

$profile = LaravelAuth::resolveSocialUser('google');

Link:

$linked = LaravelAuth::syncSocialAccount(auth()->user(), 'google', $profile);

Find local user:

$user = LaravelAuth::findUserBySocialAccount('google', $profile);

List linked accounts:

$accounts = LaravelAuth::linkedSocialAccounts(auth()->user());

Unlink:

LaravelAuth::unlinkSocialAccount(auth()->user(), 'google');

Demo Static Social Controller

<?php

namespace App\Http\Controllers\Auth;

use Illuminate\Routing\Controller;
use LaravelAuth;

class SocialAuthController extends Controller
{
    public function redirect(string $provider)
    {
        return LaravelAuth::redirectToSocialProvider($provider);
    }

    public function callback(string $provider)
    {
        $profile = LaravelAuth::resolveSocialUser($provider);
        $user = LaravelAuth::findUserBySocialAccount($provider, $profile);

        if (! $user) {
            return redirect('/login')->withErrors([
                'email' => 'No linked account found.',
            ]);
        }

        auth()->login($user);

        return redirect('/dashboard');
    }

    public function connect(string $provider)
    {
        return LaravelAuth::redirectToSocialProvider($provider);
    }

    public function connectCallback(string $provider)
    {
        $profile = LaravelAuth::resolveSocialUser($provider);

        LaravelAuth::syncSocialAccount(auth()->user(), $provider, $profile);

        return redirect('/settings/connections')->with('status', 'Provider linked.');
    }
}

Runtime Tenant Social Login

Use runtime config when each tenant stores its own OAuth credentials.

Supported runtime keys:

  • client_id
  • client_secret
  • redirect
  • scopes
  • with
  • stateless

Tenant provider discovery:

$providers = LaravelAuth::socialProviders([
    'google' => [
        'client_id' => $tenant->google_client_id,
        'client_secret' => $tenant->google_client_secret,
        'redirect' => route('tenant.social.callback', ['provider' => 'google']),
    ],
    'github' => [
        'client_id' => $tenant->github_client_id,
        'client_secret' => $tenant->github_client_secret,
        'redirect' => route('tenant.social.callback', ['provider' => 'github']),
    ],
]);

Named-parameter redirect:

return LaravelAuth::redirectToSocialProvider(
    'google',
    runtimeConfig: [
        'client_id' => $tenant->google_client_id,
        'client_secret' => $tenant->google_client_secret,
        'redirect' => route('tenant.social.callback', ['provider' => 'google']),
        'scopes' => ['openid', 'profile', 'email'],
        'stateless' => true,
    ],
);

Positional redirect:

$config = [
    'client_id' => $tenant->google_client_id,
    'client_secret' => $tenant->google_client_secret,
    'redirect' => route('tenant.social.callback', ['provider' => 'google']),
    'scopes' => ['openid', 'profile', 'email'],
    'stateless' => true,
];

return LaravelAuth::redirectToSocialProvider('google', [], [], null, $config);

Named-parameter callback resolution:

$profile = LaravelAuth::resolveSocialUser(
    'github',
    runtimeConfig: [
        'client_id' => $tenant->github_client_id,
        'client_secret' => $tenant->github_client_secret,
        'redirect' => route('tenant.social.callback', ['provider' => 'github']),
        'stateless' => true,
    ],
);

Positional callback resolution:

$profile = LaravelAuth::resolveSocialUser('github', null, $config);

Demo Tenant Social Controller

<?php

namespace App\Http\Controllers\Auth;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use LaravelAuth;

class TenantSocialAuthController extends Controller
{
    public function redirect(Request $request, string $provider)
    {
        $config = $this->runtimeConfig($request, $provider);

        return LaravelAuth::redirectToSocialProvider($provider, [], [], null, $config);
    }

    public function callback(Request $request, string $provider)
    {
        $config = $this->runtimeConfig($request, $provider);
        $profile = LaravelAuth::resolveSocialUser($provider, null, $config);
        $user = LaravelAuth::findUserBySocialAccount($provider, $profile);

        if (! $user) {
            return redirect('/login')->withErrors([
                'email' => 'No linked account found for this social login.',
            ]);
        }

        auth()->login($user);

        return redirect('/dashboard');
    }

    public function connect(Request $request, string $provider)
    {
        $config = $this->runtimeConfig($request, $provider);

        return LaravelAuth::redirectToSocialProvider($provider, [], [], null, $config);
    }

    public function connectCallback(Request $request, string $provider)
    {
        $config = $this->runtimeConfig($request, $provider);
        $profile = LaravelAuth::resolveSocialUser($provider, null, $config);

        LaravelAuth::syncSocialAccount($request->user(), $provider, $profile);

        return redirect('/settings/connections')->with('status', 'Provider linked.');
    }

    private function runtimeConfig(Request $request, string $provider): array
    {
        $tenant = tenant();
        $oauth = $tenant->oauthProviders()->where('provider', $provider)->first();

        abort_unless($oauth, 404, 'Provider not configured for this tenant.');

        return [
            'client_id' => $oauth->client_id,
            'client_secret' => $oauth->client_secret,
            'redirect' => route('tenant.social.callback', ['provider' => $provider]),
            'scopes' => $this->defaultScopes($provider),
            'stateless' => true,
        ];
    }

    private function defaultScopes(string $provider): array
    {
        return match ($provider) {
            'google' => ['openid', 'profile', 'email'],
            'github' => ['read:user', 'user:email'],
            default => [],
        };
    }
}

Suggested routes:

use App\Http\Controllers\Auth\TenantSocialAuthController;
use Illuminate\Support\Facades\Route;

Route::get('/auth/{provider}', [TenantSocialAuthController::class, 'redirect'])
    ->name('tenant.social.redirect');

Route::get('/auth/{provider}/callback', [TenantSocialAuthController::class, 'callback'])
    ->name('tenant.social.callback');

Route::middleware('auth')->group(function () {
    Route::get('/settings/connections/{provider}', [TenantSocialAuthController::class, 'connect'])
        ->name('tenant.social.connect');

    Route::get('/settings/connections/{provider}/callback', [TenantSocialAuthController::class, 'connectCallback'])
        ->name('tenant.social.connect.callback');
});

Suggested tenant credential table in your app:

tenant_oauth_providers
id
tenant_id
provider
client_id
client_secret
created_at
updated_at

Important Tenant Limitation

Runtime tenant credentials are supported, but linked social identities are still globally unique by:

  • provider
  • provider_user_id

That means the same Google or GitHub identity cannot currently be linked separately in multiple tenants through the package table.

Current package fit:

  • good for tenant-specific OAuth credentials
  • good for integer, UUID, ULID, or string-like local user IDs
  • good when social identity should remain globally unique across the whole app

Not yet built into the package:

  • tenant-scoped uniqueness for laravel_auth_social_accounts
  • tenant-aware social account lookup columns like tenant_type / tenant_id

Session State Helpers

$state = LaravelAuth::state($user = null);
$verified = LaravelAuth::isVerified($user = null);
$full = LaravelAuth::isFullyAuthenticated($user = null);
$pending = LaravelAuth::isPending2FA($user = null);
$required = LaravelAuth::requiresTwoFactor($user);

Enforce manually if needed:

LaravelAuth::enforce($user = null);

Rate Limiting Helpers

LaravelAuth::throttle('otp', $user = null);
LaravelAuth::tooManyAttempts('otp', $user = null);
LaravelAuth::clearThrottle('otp', $user = null);

Buckets used by the package:

  • otp
  • passkey

Strict Preset

Apply the built-in strict preset:

LaravelAuth::preset('strict');

This tightens:

  • enforced 2FA
  • OTP TTL and max attempts
  • passkey throttle settings
  • trusted-device IP binding
  • WebAuthn verification requirements

OTP Provider Configuration

Mail

Enabled by default.

Relevant config:

  • otp_channels.email.enabled
  • otp_channels.email.view
  • otp_channels.email.subject

SMS providers

Supported built-ins:

  • Twilio
  • Vonage
  • MessageBird
  • MSG91
  • custom

Set the provider in config:

'otp_channels' => [
    'sms' => [
        'provider' => env('LARAVEL_AUTH_SMS_PROVIDER', 'twilio'),
    ],
],

WhatsApp providers

Supported built-ins:

  • Twilio
  • custom

WhatsApp is disabled by default. Enable it in your published config before use.

Custom transports

Publish stubs:

php artisan laravel-auth:otp:publish

Then wire your own classes in the published config:

'sms' => [
    'provider' => 'custom',
    'custom_transport' => App\LaravelAuth\SmsOtpTransport::class,
],

'whatsapp' => [
    'enabled' => true,
    'provider' => 'custom',
    'custom_transport' => App\LaravelAuth\WhatsAppOtpTransport::class,
],

Troubleshooting

2FA required unexpectedly

  • confirm the user session completed a second factor
  • confirm the verification endpoint calls an attempt* method
  • confirm the trusted-device cookie still matches the request

Passkey challenge invalid or expired

  • send challenge_id back from the frontend
  • do not reuse the same challenge
  • confirm RP ID matches the real app hostname

Social provider missing

  • confirm the provider is enabled in laravel-auth.social.providers
  • confirm static config has client_id, client_secret, and redirect
  • for runtime config, confirm client_id and client_secret are present
  • if runtime config omits redirect, make sure a fallback redirect exists in laravel-auth.social.providers.{provider}.redirect

Runtime config named parameter error in IDE

If your app or IDE says Unknown named parameter $runtimeConfig, refresh the package in the app:

composer update ghostcompiler/laravel-auth
composer dump-autoload
php artisan optimize:clear

Or use positional arguments:

LaravelAuth::redirectToSocialProvider($provider, [], [], null, $config);
LaravelAuth::resolveSocialUser($provider, null, $config);

OTP verification keeps failing

  • confirm destination matches the original challenge destination
  • confirm provider credentials and sender configuration
  • confirm the code is not expired
  • confirm the attempt bucket is not rate limited

Testing & Quality Control

To run the automated test suite and ensure all quality gates pass, use the following commands:

# Run the PHPUnit test suite (Unit + Feature)
composer test

# Run PHPStan static analysis
composer analyse

# Run Psalm type checker
composer types

# Check code style with Pint
composer lint

# Automatically format code style with Pint
composer format

# Run Rector code refactoring / modernisation (dry-run)
composer refactor

# Verify composer dependencies security audit
composer audit --no-dev

Public API Reference

2FA / TOTP / Recovery

LaravelAuth::enable2FA($user);
LaravelAuth::confirmTwoFactorSetup($user, $code);
LaravelAuth::disable2FA($user);
LaravelAuth::verifyOTP($user, $code);
LaravelAuth::generateRecoveryCodes($user);

Passkeys

LaravelAuth::registerPasskey($user, $name = null);
LaravelAuth::finishPasskeyRegistration($user, $payload, $name = null);
LaravelAuth::requestPasskeyAssertion($user);
LaravelAuth::verifyPasskeyAssertion($user, $payload);
LaravelAuth::attemptPasskey($user, $payload, $rememberDevice = false, $deviceName = null);

OTP Channels

LaravelAuth::sendEmailOtp($user, $email = null, $context = []);
LaravelAuth::verifyEmailOtp($user, $code, $email = null, $context = []);
LaravelAuth::attemptEmailOtp($user, $code, $email = null, $context = []);

LaravelAuth::sendSmsOtp($user, $phoneNumber, $context = []);
LaravelAuth::verifySmsOtp($user, $phoneNumber, $code, $context = []);
LaravelAuth::attemptSmsOtp($user, $phoneNumber, $code, $context = []);

LaravelAuth::sendWhatsAppOtp($user, $phoneNumber, $context = []);
LaravelAuth::verifyWhatsAppOtp($user, $phoneNumber, $code, $context = []);
LaravelAuth::attemptWhatsAppOtp($user, $phoneNumber, $code, $context = []);

Social Helpers

LaravelAuth::socialProviders($runtimeProviders = []);
LaravelAuth::redirectToSocialProvider($provider, $scopes = [], $with = [], $stateless = null, $runtimeConfig = []);
LaravelAuth::resolveSocialUser($provider, $stateless = null, $runtimeConfig = []);
LaravelAuth::syncSocialAccount($user, $provider, $profile);
LaravelAuth::findUserBySocialAccount($provider, $socialIdentity);
LaravelAuth::linkedSocialAccounts($user);
LaravelAuth::unlinkSocialAccount($user, $provider, $providerUserId = null);

State / Enforcement / Utility

LaravelAuth::state($user = null);
LaravelAuth::isVerified($user = null);
LaravelAuth::isFullyAuthenticated($user = null);
LaravelAuth::isPending2FA($user = null);
LaravelAuth::enforce($user = null);
LaravelAuth::throttle('otp', $user = null);
LaravelAuth::tooManyAttempts('otp', $user = null);
LaravelAuth::clearThrottle('otp', $user = null);
LaravelAuth::preset('strict');
LaravelAuth::requiresTwoFactor($user);

License

The MIT License (MIT). Please see License File for more information.

Development Environment

Built using ServBay

ServBay

  • Mac M4 Tested
  • macOS Apple Silicon
  • Powered by ServBay

Star History Chart