Headless Laravel authentication security with TOTP 2FA, passkeys, trusted devices, recovery codes, and Socialite-powered social login helpers.
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
Premium, headless Laravel authentication hardening library for TOTP 2FA, passkeys, recovery codes, multi-channel OTP, trusted devices, and social authentication.
Headless Laravel authentication hardening with:
This package does not replace your login system. It adds security layers on top of your existing auth flow.
laravel/socialite support for social login helperscomposer 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.phpresources/views/vendor/laravel-authapp/LaravelAuthPublish only OTP assets later if needed:
php artisan laravel-auth:otp:publish
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
Middleware aliases:
2falaravel-auth.2falaravel-auth.enforcelaravel-auth.throttlePublished config:
Main facade contract:
src/Contracts/LaravelAuthManager.phpSingle package migration:
Database objects created:
laravel_auth_totp_secretlaravel_auth_two_factor_enabledlaravel_auth_confirmed_atlaravel_auth_recovery_codeslaravel_auth_trusted_deviceslaravel_auth_passkeyslaravel_auth_webauthn_challengeslaravel_auth_social_accountslaravel_auth_otp_challengesFrom the package config:
enforce_2fa is trueweb middleware groupfalseuse 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']);
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());
<?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']);
}
}
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'));
Generate or regenerate recovery codes:
$codes = LaravelAuth::generateRecoveryCodes(auth()->user());
Recovery codes are consumed automatically when passed through verifyOTP() or attemptOtp().
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'
);
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'));
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')
);
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')
);
<?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 are created automatically when you use:
attemptOtp(..., rememberDevice: true, ...)attemptPasskey(..., rememberDevice: true, ...)Relevant config keys:
trusted_devices.cookietrusted_devices.ttl_daystrusted_devices.bind_user_agenttrusted_devices.bind_ipLaravelAuth supports two social-login modes:
Backward compatibility is preserved. Existing static Socialite-based setups continue to work.
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');
<?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.');
}
}
Use runtime config when each tenant stores its own OAuth credentials.
Supported runtime keys:
client_idclient_secretredirectscopeswithstatelessTenant 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);
<?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
Runtime tenant credentials are supported, but linked social identities are still globally unique by:
providerprovider_user_idThat means the same Google or GitHub identity cannot currently be linked separately in multiple tenants through the package table.
Current package fit:
Not yet built into the package:
laravel_auth_social_accountstenant_type / tenant_id$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);
LaravelAuth::throttle('otp', $user = null);
LaravelAuth::tooManyAttempts('otp', $user = null);
LaravelAuth::clearThrottle('otp', $user = null);
Buckets used by the package:
otppasskeyApply the built-in strict preset:
LaravelAuth::preset('strict');
This tightens:
Enabled by default.
Relevant config:
otp_channels.email.enabledotp_channels.email.viewotp_channels.email.subjectSupported built-ins:
Set the provider in config:
'otp_channels' => [
'sms' => [
'provider' => env('LARAVEL_AUTH_SMS_PROVIDER', 'twilio'),
],
],
Supported built-ins:
WhatsApp is disabled by default. Enable it in your published config before use.
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,
],
attempt* methodchallenge_id back from the frontendlaravel-auth.social.providersclient_id, client_secret, and redirectclient_id and client_secret are presentredirect, make sure a fallback redirect exists in laravel-auth.social.providers.{provider}.redirectIf 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);
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
LaravelAuth::enable2FA($user);
LaravelAuth::confirmTwoFactorSetup($user, $code);
LaravelAuth::disable2FA($user);
LaravelAuth::verifyOTP($user, $code);
LaravelAuth::generateRecoveryCodes($user);
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);
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 = []);
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);
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);
The MIT License (MIT). Please see License File for more information.
Built using ServBay