ghostcompiler / secure-auth
Headless Laravel authentication security with TOTP 2FA, passkeys, trusted devices, recovery codes, and Socialite-powered social login helpers.
Requires
- php: ^8.2
- illuminate/http: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/mail: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/view: ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0
- laravel/socialite: ^5.0
- lbuchs/webauthn: ^2.2
Requires (Dev)
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.5
README
SecureAuth
SecureAuth is a headless Laravel security package for production-grade authentication hardening.
It adds:
- Passkeys (WebAuthn)
- TOTP 2FA
- Email OTP
- SMS OTP
- WhatsApp OTP
- Trusted Devices
- Recovery Codes
- Rate Limiting
- Publishable OTP templates and transport stubs
Security Notice
SecureAuth does NOT replace Laravel authentication.
It sits on top of your existing login flow and adds secure second-factor verification.
To use it safely:
- enable
enforce_2fa - regenerate sessions after login
- protect verification routes with
auth - use the built-in
attempt*methods where possible
Installation
composer require ghostcompiler/secure-auth php artisan secure-auth:install php artisan migrate
Commands
Install
php artisan secure-auth:install
Publishes:
config/secure-auth.php- package migrations
- OTP views
- OTP transport stubs
Publish OTP Assets
php artisan secure-auth:otp:publish
Publishes:
- OTP mail and message templates
- custom SMS transport stub
- custom WhatsApp transport stub
Use --force with either command if you want to overwrite published files.
Published Files
Config
config/secure-auth.php
What it is used for:
- enables or disables global 2FA enforcement
- selects WebAuthn security defaults
- controls OTP channel length, TTL, and max attempts
- chooses email, SMS, and WhatsApp providers
- points custom SMS and WhatsApp channels to your application transport classes
- defines the default views used to render OTP email and message bodies
Views
resources/views/vendor/secure-auth/mail/layout.blade.php
resources/views/vendor/secure-auth/mail/otp.blade.php
resources/views/vendor/secure-auth/messages/sms.blade.php
resources/views/vendor/secure-auth/messages/whatsapp.blade.php
What each file is used for:
mail/layout.blade.phpwraps OTP email content in a reusable email shellmail/otp.blade.phprenders the actual OTP email body and is the main email template you customizemessages/sms.blade.phprenders the plain-text SMS body sent through built-in or custom SMS providersmessages/whatsapp.blade.phprenders the plain-text WhatsApp body sent through built-in or custom WhatsApp providers
When you should publish them:
- publish views when you want to rebrand the email
- publish views when you want to change the wording of SMS or WhatsApp OTP messages
- publish views when you want to inject extra context values such as company name, support URL, device label, or request location
Transport Stubs
app/SecureAuth/SmsOtpTransport.php
app/SecureAuth/WhatsAppOtpTransport.php
What each stub is used for:
app/SecureAuth/SmsOtpTransport.phpplace your manual SMS provider API integration here when you do not want to use the built-in providersapp/SecureAuth/WhatsAppOtpTransport.phpplace your manual WhatsApp provider API integration here when you want to call your own API, gateway, or BSP
When you should publish them:
- publish stubs when your provider is not one of the built-in transports
- publish stubs when your provider requires a custom send endpoint and a custom verify endpoint
- publish stubs when you want provider-specific signing, headers, payload formats, or fallback logic
Authentication Flow
Recommended flow:
Login →
Passkey (Primary)
OR Password →
TOTP / Email OTP / SMS OTP / WhatsApp OTP →
Trusted Device →
Fully Authenticated
Auth States
SecureAuth stores the auth state in session under:
secure_auth.state
Possible values:
guestpassword_verifiedtwo_factor_pendingfully_authenticated
Helpers:
SecureAuth::state(); SecureAuth::isFullyAuthenticated(); SecureAuth::isPending2FA(); SecureAuth::isVerified(); SecureAuth::requiresTwoFactor($user);
Middleware
Route-Level Enforcement
Route::middleware(['auth', 'secure-auth.2fa'])->group(function () { Route::get('/billing', BillingController::class); });
Throttling
Route::post('/security/otp/email/verify', VerifyEmailOtpController::class) ->middleware(['auth', 'secure-auth.throttle:otp']); Route::post('/security/otp/sms/verify', VerifySmsOtpController::class) ->middleware(['auth', 'secure-auth.throttle:otp']); Route::post('/security/otp/whatsapp/verify', VerifyWhatsAppOtpController::class) ->middleware(['auth', 'secure-auth.throttle:otp']); Route::post('/security/passkeys/verify', VerifyPasskeyController::class) ->middleware(['auth', 'secure-auth.throttle:passkey']);
Global Enforcement
When enforce_2fa is enabled, SecureAuth automatically pushes enforcement middleware into configured middleware groups.
Configuration
Main defaults:
'enforce_2fa' => true, 'proof_ttl_seconds' => 300, 'otp_channels' => [ 'length' => 6, 'ttl_seconds' => 300, 'max_attempts' => 5, ], 'webauthn' => [ 'user_verification' => 'required', ],
Strict Preset
SecureAuth::preset('strict');
This enables the package's stricter defaults for 2FA enforcement, OTP limits, trusted devices, and WebAuthn verification.
Public API
TOTP / Passkeys
SecureAuth::enable2FA($user); SecureAuth::confirmTwoFactorSetup($user, $code); SecureAuth::disable2FA($user); SecureAuth::verifyOTP($user, $code); SecureAuth::generateRecoveryCodes($user); SecureAuth::registerPasskey($user, $name = null); SecureAuth::finishPasskeyRegistration($user, $payload, $name = null); SecureAuth::requestPasskeyAssertion($user); SecureAuth::verifyPasskeyAssertion($user, $payload); SecureAuth::attemptOtp($user, $code, $rememberDevice = false, $deviceName = null); SecureAuth::attemptPasskey($user, $payload, $rememberDevice = false, $deviceName = null);
Email OTP
SecureAuth::sendEmailOtp($user, $email = null, $context = []); SecureAuth::verifyEmailOtp($user, $code, $email = null, $context = []); SecureAuth::attemptEmailOtp($user, $code, $email = null, $context = []);
SMS OTP
SecureAuth::sendSmsOtp($user, $phoneNumber, $context = []); SecureAuth::verifySmsOtp($user, $phoneNumber, $code, $context = []); SecureAuth::attemptSmsOtp($user, $phoneNumber, $code, $context = []);
WhatsApp OTP
SecureAuth::sendWhatsAppOtp($user, $phoneNumber, $context = []); SecureAuth::verifyWhatsAppOtp($user, $phoneNumber, $code, $context = []); SecureAuth::attemptWhatsAppOtp($user, $phoneNumber, $code, $context = []);
State / Security Helpers
SecureAuth::state(); SecureAuth::isVerified(); SecureAuth::isFullyAuthenticated(); SecureAuth::isPending2FA(); SecureAuth::enforce(); SecureAuth::throttle('otp'); SecureAuth::clearThrottle('otp'); SecureAuth::preset('strict');
Social Login Helpers
SecureAuth::socialProviders(); SecureAuth::redirectToSocialProvider($provider, $scopes = [], $with = [], $stateless = null); SecureAuth::resolveSocialUser($provider, $stateless = null); SecureAuth::syncSocialAccount($user, $provider, $profile); SecureAuth::findUserBySocialAccount($provider, $socialIdentity); SecureAuth::linkedSocialAccounts($user); SecureAuth::unlinkSocialAccount($user, $provider, $providerUserId = null);
Return Shapes
enable2FA()
Returns:
[
'secret' => 'BASE32SECRET',
'otpauth_uri' => 'otpauth://totp/...',
]
confirmTwoFactorSetup()
Returns:
[
'recovery_codes' => [
'ABCD-EFGH-IJKL',
'MNOP-QRST-UVWX',
],
'proof' => VerifiedFactor,
]
registerPasskey()
Returns:
[
'challenge_id' => '...',
'publicKey' => [
// WebAuthn create options
],
]
requestPasskeyAssertion()
Returns:
[
'challenge_id' => '...',
'publicKey' => [
// WebAuthn get options
],
]
sendEmailOtp(), sendSmsOtp(), sendWhatsAppOtp()
All channel send methods return:
[
'channel' => 'email', // or sms / whatsapp
'destination' => 'user@example.com',
'expires_at' => '2026-04-17T12:00:00+00:00',
]
attempt*() methods
The safe high-level verification methods return:
truewhen the verification succeeds and the session becomes fully authenticatedfalsewhen the code is invalid
verify*() methods
The low-level verification methods return:
VerifiedFactoron successnullon invalid code
TOTP Usage
Start TOTP Setup
$setup = SecureAuth::enable2FA($user);
Confirm TOTP Setup
$result = SecureAuth::confirmTwoFactorSetup($user, $code);
Use TOTP in Login Verification
if (! SecureAuth::attemptOtp($user, $code)) { return response()->json(['message' => 'Invalid OTP'], 422); }
Passkey Usage
Start Registration
$options = SecureAuth::registerPasskey($user, 'MacBook Touch ID');
Finish Registration
$passkey = SecureAuth::finishPasskeyRegistration($user, [ 'challenge_id' => $request->challenge_id, 'id' => $request->id, 'rawId' => $request->rawId, 'type' => $request->type, 'clientDataJSON' => data_get($request->response, 'clientDataJSON'), 'attestationObject' => data_get($request->response, 'attestationObject'), 'transports' => $request->transports, ], $request->name);
Start Authentication
$options = SecureAuth::requestPasskeyAssertion($user);
Verify Authentication
if (! SecureAuth::attemptPasskey($user, [ 'challenge_id' => $request->challenge_id, 'id' => $request->id, 'rawId' => $request->rawId, 'type' => $request->type, 'clientDataJSON' => data_get($request->response, 'clientDataJSON'), 'authenticatorData' => data_get($request->response, 'authenticatorData'), 'signature' => data_get($request->response, 'signature'), ])) { return response()->json(['message' => 'Invalid passkey'], 422); }
Email OTP Usage
Send
$result = SecureAuth::sendEmailOtp($user);
You can also override the destination:
$result = SecureAuth::sendEmailOtp($user, 'user@example.com');
Verify
if (! SecureAuth::attemptEmailOtp($user, $code)) { return response()->json(['message' => 'Invalid email OTP'], 422); }
Context
All channel send and verify methods accept $context = [].
That context is merged into the rendered view data, so you can pass additional values for custom templates:
SecureAuth::sendEmailOtp($user, null, [ 'appName' => config('app.name'), 'supportEmail' => 'help@example.com', ]);
SMS OTP Usage
Send
$result = SecureAuth::sendSmsOtp($user, '+15550001111');
Verify
if (! SecureAuth::attemptSmsOtp($user, '+15550001111', $code)) { return response()->json(['message' => 'Invalid SMS OTP'], 422); }
Built-In SMS Providers
Supported provider transport structure:
- Twilio
- Vonage
- MessageBird
- MSG91
- custom SMS transport
Config example:
'otp_channels' => [ 'sms' => [ 'provider' => env('SECURE_AUTH_SMS_PROVIDER', 'twilio'), 'custom_transport' => \GhostCompiler\SecureAuth\OTP\Transport\CustomSmsOtpTransport::class, ], ],
Custom SMS API Setup
Publish assets:
php artisan secure-auth:otp:publish
Edit:
app/SecureAuth/SmsOtpTransport.php
That file includes:
public function sendCode(...) public function verifyCode(...)
Full contract:
public function sendCode( Authenticatable $user, OtpChallenge $challenge, string $message, array $context = [] ): void; public function verifyCode( Authenticatable $user, OtpChallenge $challenge, string $code, array $context = [] ): bool;
What SecureAuth passes to sendCode():
$userthe authenticated user model the OTP belongs to$challengethe persisted OTP challenge row, including channel, destination, expiry, and metadata$messagethe final rendered SMS body frommessages/sms.blade.php$contextmerged runtime context
Default $context keys passed by SecureAuth:
codethe generated OTP codedestinationthe phone number the code is being sent to- any custom keys you passed into
SecureAuth::sendSmsOtp($user, $destination, $context)
What verifyCode() must return:
truethe external provider accepted the code as validfalsethe provider rejected the code
Important behavior:
- SecureAuth also verifies the locally stored hashed OTP before returning success
- returning
truefrom your provider alone is not enough to authenticate the user attemptSmsOtp()marks the session as verified only when provider verification and local hash verification both pass
Point config to your application class:
'otp_channels' => [ 'sms' => [ 'provider' => 'custom', 'custom_transport' => \App\SecureAuth\SmsOtpTransport::class, ], ],
WhatsApp OTP Usage
Send
$result = SecureAuth::sendWhatsAppOtp($user, '+15550002222');
Verify
if (! SecureAuth::attemptWhatsAppOtp($user, '+15550002222', $code)) { return response()->json(['message' => 'Invalid WhatsApp OTP'], 422); }
Built-In WhatsApp Providers
Supported structure:
- Twilio WhatsApp transport
- custom WhatsApp transport
Config example:
'otp_channels' => [ 'whatsapp' => [ 'provider' => env('SECURE_AUTH_WHATSAPP_PROVIDER', 'custom'), 'custom_transport' => \GhostCompiler\SecureAuth\OTP\Transport\CustomWhatsAppOtpTransport::class, ], ],
Custom WhatsApp API Setup
Publish assets:
php artisan secure-auth:otp:publish
Edit:
app/SecureAuth/WhatsAppOtpTransport.php
That file includes:
public function sendCode(...) public function verifyCode(...)
Full contract:
public function sendCode( Authenticatable $user, OtpChallenge $challenge, string $message, array $context = [] ): void; public function verifyCode( Authenticatable $user, OtpChallenge $challenge, string $code, array $context = [] ): bool;
What SecureAuth passes to sendCode():
$userthe authenticated user model the OTP belongs to$challengethe persisted OTP challenge row, including channel, destination, expiry, and metadata$messagethe final rendered WhatsApp message frommessages/whatsapp.blade.php$contextmerged runtime context
Default $context keys passed by SecureAuth:
codethe generated OTP codedestinationthe WhatsApp destination number- any custom keys you passed into
SecureAuth::sendWhatsAppOtp($user, $destination, $context)
What verifyCode() must return:
truethe external provider accepted the code as validfalsethe provider rejected the code
Important behavior:
- SecureAuth still validates the local hashed OTP challenge before authenticating
- your custom provider verification is an additional gate, not the only gate
attemptWhatsAppOtp()marks the session as verified only when both checks pass
Point config to your application class:
'otp_channels' => [ 'whatsapp' => [ 'provider' => 'custom', 'custom_transport' => \App\SecureAuth\WhatsAppOtpTransport::class, ], ],
OTP View Variables
SecureAuth renders OTP templates through OtpTemplateRenderer.
Every OTP view receives:
$user$channel$code$expiresInMinutes
Any values passed through $context are also available in the view.
How values are injected:
SecureAuth::sendEmailOtp($user, $email, ['company' => 'Ghost Compiler'])makes$companyavailable inside the email viewSecureAuth::sendSmsOtp($user, '+15550001111', ['supportUrl' => 'https://example.com/help'])makes$supportUrlavailable inside the SMS viewSecureAuth::sendWhatsAppOtp($user, '+15550002222', ['deviceLabel' => 'MacBook Pro'])makes$deviceLabelavailable inside the WhatsApp view
Common customization pattern:
- keep
$codeand$expiresInMinutesin every message - add your own context values for branding or request-specific metadata
- avoid removing the expiry warning unless your own text clearly communicates validity duration
mail/otp.blade.php
Default usage:
- shows
$code - shows
$expiresInMinutes - uses the mail layout component
- can also render any custom context keys you pass when calling
sendEmailOtp()
Available variables:
$user$channel$code$expiresInMinutes- any custom
$contextkeys
messages/sms.blade.php
Default usage:
- plain text SMS body
- intended to output a single compact line that mobile carriers can deliver reliably
Available variables:
$user$channel$code$expiresInMinutes- any custom
$contextkeys
messages/whatsapp.blade.php
Default usage:
- plain text WhatsApp body
- intended to output a short readable message that can also be adapted to a template-based provider if needed
Available variables:
$user$channel$code$expiresInMinutes- any custom
$contextkeys
Mail Layout Variables
mail/layout.blade.php receives:
$subject$slot
What those variables mean:
$subjectthe mail subject configured insecure-auth.otp_channels.email.subject$slotthe rendered inner HTML frommail/otp.blade.php
The OTP mail view passes the subject automatically from:
config('secure-auth.otp_channels.email.subject')
Output of Published Templates
Default Email Output
The email layout renders:
- a heading
- expiry text
- the OTP code
- a fallback notice if the user did not request it
Default result:
- HTML email layout with a card-style body
- subject line from
secure-auth.otp_channels.email.subject - OTP code printed inside the email body for manual entry into your UI
Default SMS Output
The default SMS message renders as:
Your SecureAuth verification code is 123456. It expires in 5 minutes.
Default WhatsApp Output
The default WhatsApp message renders as:
Your SecureAuth WhatsApp verification code is 123456. It expires in 5 minutes.
Custom Transport Inputs And Outputs
Both custom transport stubs implement:
GhostCompiler\SecureAuth\Contracts\OtpTransport
Method expectations:
sendCode(...)should dispatch the message to your provider and throw an exception if sending failsverifyCode(...)should return a boolean provider verdict and must not mutate session state directly
SecureAuth-side output after send:
[
'channel' => 'sms',
'destination' => '+15550001111',
'expires_at' => '2026-04-17T18:30:00+00:00',
]
SecureAuth-side output after verify:
verifyEmailOtp(),verifySmsOtp(),verifyWhatsAppOtp()return aVerifiedFactorobject on success ornullon failureattemptEmailOtp(),attemptSmsOtp(),attemptWhatsAppOtp()returntrueon success orfalseon failure and also finalize the session proof internally
Social Login Helpers
SecureAuth also includes Socialite helpers for linking providers.
Available Methods
SecureAuth::socialProviders(); SecureAuth::redirectToSocialProvider('google'); SecureAuth::resolveSocialUser('google'); SecureAuth::syncSocialAccount($user, 'google', $profile); SecureAuth::findUserBySocialAccount('google', $profile); SecureAuth::linkedSocialAccounts($user); SecureAuth::unlinkSocialAccount($user, 'google');
Best Practices
- Always enable
enforce_2fa - Use HTTPS
- Regenerate sessions after login
- Keep OTP verification routes behind
auth - Prefer
attempt*()methods for production flows - Use published templates instead of hardcoding OTP message strings in controllers
- Use custom transport stubs when your SMS or WhatsApp provider is not covered by the built-in transports
Testing
Current package tests cover:
- TOTP success and failure
- passkey success
- replay prevention
- trusted-device validation
- middleware enforcement
- email OTP
- SMS OTP
- custom SMS transport
- WhatsApp OTP
- custom WhatsApp transport
Contributing
PRs welcome.
License
MIT
