biponix / laravel-secure-otp
Laravel OTP generator and validator
Fund package maintenance!
biponix
Installs: 35
Dependents: 0
Suggesters: 0
Security: 0
Stars: 12
Watchers: 1
Forks: 0
Open Issues: 0
pkg:composer/biponix/laravel-secure-otp
Requires
- php: ^8.1|^8.2|^8.3|^8.4
- illuminate/contracts: ^10.0|^11.0|^12.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.7.0|^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-type-coverage: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
A production-ready, secure OTP (One-Time Password) package for Laravel applications. Generate and verify OTP codes via Email, SMS, WhatsApp, or any Laravel notification channel.
Features
- ✅ Production-Grade Security: HMAC-based storage with secret key, timing-attack resistant verification
- ✅ Multi-Channel Support: Email, SMS, WhatsApp, Telegram (via Laravel Notifications)
- ✅ Pluggable Identifier Types: Extensible validation/normalization for emails, phones, usernames, user IDs, etc.
- ✅ Context-Safe: Works seamlessly in HTTP, queue workers, and console commands
- ✅ Context-Aware Rate Limiting: Separate limits for generation vs verification (brute force protection)
- ✅ Multi-Layer Protection: Per-identifier + per-IP rate limiting in HTTP contexts
- ✅ Attack Prevention: Replay attack prevention, race condition protection with distributed cache locks
- ✅ Fully Customizable: Custom notification classes, configurable expiry, length, attempts
- ✅ Security Logging: Detailed audit logs with privacy-preserving PII masking
- ✅ 100% Test Coverage: 104 comprehensive tests ensuring reliability
- ✅ Wide Compatibility: PHP 8.1-8.4, Laravel 10-12
Installation
You can install the package via composer:
composer require biponix/laravel-secure-otp
Run the migrations:
php artisan migrate
The migrations will run automatically from the package. If you need to customize the migration, you can publish it first:
php artisan vendor:publish --tag="secure-otp-migrations"
php artisan migrate
Publish the config file (optional):
php artisan vendor:publish --tag="secure-otp-config"
Configuration
The config file (config/secure-otp.php) allows you to customize:
return [ // OTP expiry in minutes (default: 5) 'expiry_minutes' => env('OTP_EXPIRY_MINUTES', 5), // OTP code length (default: 6 digits) 'length' => env('OTP_LENGTH', 6), // Maximum verification attempts (default: 3) 'max_attempts' => env('OTP_MAX_ATTEMPTS', 3), // Hash algorithm and secret for HMAC (prevents rainbow table attacks) 'hash_algorithm' => env('OTP_HASH_ALGORITHM', 'sha256'), 'hash_secret' => env('OTP_HASH_SECRET', null), // Falls back to app.key if null // Context-aware rate limiting (separate limits for generation vs verification) 'rate_limits' => [ // Cache key prefix (prevents collisions in shared cache) 'prefix' => env('OTP_RATE_LIMIT_PREFIX', 'secure-otp'), // Shared defaults (used for both generate and verify if context-specific not set) 'per_identifier' => [ 'max_attempts' => env('OTP_RATE_LIMIT_IDENTIFIER', 3), 'decay_seconds' => env('OTP_RATE_LIMIT_IDENTIFIER_DECAY', 3600), // 1 hour ], 'per_ip' => [ 'max_attempts' => env('OTP_RATE_LIMIT_IP', 10), 'decay_seconds' => env('OTP_RATE_LIMIT_IP_DECAY', 3600), // 1 hour ], // Optional: Override limits specifically for verification (prevent brute force) 'verify_per_identifier' => [ 'max_attempts' => env('OTP_VERIFY_RATE_LIMIT_IDENTIFIER', 5), 'decay_seconds' => env('OTP_VERIFY_RATE_LIMIT_IDENTIFIER_DECAY', 60), // 1 minute ], 'verify_per_ip' => [ 'max_attempts' => env('OTP_VERIFY_RATE_LIMIT_IP', 20), 'decay_seconds' => env('OTP_VERIFY_RATE_LIMIT_IP_DECAY', 60), // 1 minute ], ], // Custom notification class 'notification_class' => env('OTP_NOTIFICATION_CLASS', \Biponix\SecureOtp\Notifications\OtpNotification::class), // Cleanup after hours (default: 24) 'cleanup_after_hours' => env('OTP_CLEANUP_AFTER_HOURS', 24), // Enable security logging (default: true) 'enable_logging' => env('OTP_ENABLE_LOGGING', true), ];
Usage
Quick Start
Without Type Validation (Pass-through Mode)
use Biponix\SecureOtp\Services\SecureOtpService; $otp = app(SecureOtpService::class); // Send OTP to any identifier (no validation) $otp->send('01700000000'); // Bangladesh phone $otp->send('user@example.com'); // Email $otp->send('username123'); // Username $otp->send('12345'); // User ID // Verify OTP $verified = $otp->verify('01700000000', '123456');
With Type Validation (Recommended for production)
use Biponix\SecureOtp\Services\SecureOtpService; use Biponix\SecureOtp\Types\EmailType; // Register identifier types in AppServiceProvider::boot() SecureOtpService::addType('email', new EmailType()); // Now use with type parameter $otp->send('user@example.com', 'email'); // ✅ Validated & normalized $otp->verify('user@example.com', '123456', 'email');
Basic Usage Example
use Biponix\SecureOtp\Exceptions\InvalidIdentifierException; use Biponix\SecureOtp\Exceptions\RateLimitExceededException; use Biponix\SecureOtp\Exceptions\OtpGenerationException; use Biponix\SecureOtp\Services\SecureOtpService; class AuthController extends Controller { public function sendOtp(Request $request, SecureOtpService $otp) { try { // Send OTP (throws on error) $otp->send($request->email, 'email'); return response()->json([ 'message' => 'OTP sent successfully' ]); } catch (RateLimitExceededException $e) { // Rate limit exceeded return response()->json([ 'error' => 'Too many requests', 'retry_after' => $e->getRetryAfter(), ], 429); } catch (InvalidIdentifierException $e) { return response()->json(['error' => 'Invalid email address'], 400); } catch (OtpGenerationException $e) { return response()->json(['error' => 'Failed to send OTP'], 500); } } public function verifyOtp(Request $request, SecureOtpService $otp) { // verify() returns bool (doesn't expose why it failed for security) $verified = $otp->verify($request->email, $request->code, 'email'); if ($verified) { // OTP verified successfully $user = User::where('email', $request->email)->firstOrFail(); auth()->login($user); return response()->json(['message' => 'Login successful']); } return response()->json(['error' => 'Invalid or expired code'], 422); } }
Using with Dependency Injection
use Biponix\SecureOtp\Services\SecureOtpService; public function __construct( private SecureOtpService $otp ) {} public function sendCode(string $identifier): void { // Throws exceptions on error $this->otp->send($identifier); }
Custom Identifier Types
Create custom identifier types for phones, usernames, or any identifier format you need.
Step 1: Create Type Class
// app/Otp/BangladeshSmsType.php namespace App\Otp; use Biponix\SecureOtp\Contracts\OtpIdentifierType; class BangladeshSmsType extends OtpIdentifierType { /** * Normalize Bangladesh phone numbers to E.164 format */ public function normalize(string $value): string { // Remove spaces, dashes, parentheses $value = preg_replace('/[\s\-\(\)]/', '', $value); // Convert local format (01700000000) to E.164 (+8801700000000) if (preg_match('/^0\d{10}$/', $value)) { return '+880' . substr($value, 1); } return $value; } /** * Validate E.164 Bangladesh phone numbers */ public function validate(string $value): bool { // Must be +880 followed by 10 digits return preg_match('/^\+880\d{10}$/', $value) === 1; } }
Step 2: Register Type in AppServiceProvider
// app/Providers/AppServiceProvider.php use App\Otp\BangladeshSmsType; use Biponix\SecureOtp\Services\SecureOtpService; public function boot(): void { // Register custom identifier types SecureOtpService::addType('sms', new BangladeshSmsType()); }
Step 3: Use With Type Parameter
// Send OTP with validation $otp->send('01700000000', 'sms'); // ✅ Normalized to +8801700000000 $otp->send('0170-000-0000', 'sms'); // ✅ Normalized to +8801700000000 // Verify with same type $verified = $otp->verify('01700000000', '123456', 'sms'); // ✅ Works!
More Examples:
// Username type class UsernameType extends OtpIdentifierType { public function normalize(string $value): string { return strtolower(trim($value)); } public function validate(string $value): bool { return preg_match('/^[a-z0-9_]{3,20}$/', $value) === 1; } } // User ID type class UserIdType extends OtpIdentifierType { public function normalize(string $value): string { return trim($value); } public function validate(string $value): bool { return ctype_digit($value) && (int)$value > 0; } } // Register in AppServiceProvider SecureOtpService::addType('username', new UsernameType()); SecureOtpService::addType('user_id', new UserIdType()); // Usage $otp->send('john_doe', 'username'); $otp->send('12345', 'user_id');
Generate Without Sending (Custom Delivery)
use Biponix\SecureOtp\Services\SecureOtpService; use Biponix\SecureOtp\Exceptions\RateLimitExceededException; public function customDelivery(SecureOtpService $otp) { try { // Generate OTP without sending (returns string) $code = $otp->generate('user@example.com', 'email'); // Deliver via your custom method $this->sendViaSms($code); } catch (RateLimitExceededException $e) { // Handle rate limiting return response()->json([ 'error' => 'Too many requests', 'retry_after' => $e->getRetryAfter(), ], 429); } }
Send Synchronously (Block Until Sent)
// Default: queued (non-blocking) $otp->send('user@example.com'); // Force synchronous sending (blocks until sent) $otp->sendNow('user@example.com');
Using the Facade (Optional)
use Biponix\SecureOtp\Facades\SecureOtp; // Send OTP (throws exceptions on error) SecureOtp::send('user@example.com'); // Verify OTP (returns bool) $verified = SecureOtp::verify('user@example.com', '123456'); // Generate without sending (returns string, throws on rate limit) $code = SecureOtp::generate('user@example.com'); // Send synchronously (throws exceptions on error) SecureOtp::sendNow('user@example.com');
Custom Notification Channels
Create your own notification class to route OTPs via SMS, WhatsApp, or other channels based on identifier type.
Type-Based Channel Routing:
// app/Notifications/MultiChannelOtpNotification.php namespace App\Notifications; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\VonageSmsMessage; use Illuminate\Notifications\Notification; class MultiChannelOtpNotification extends Notification { public function __construct(public string $code) {} /** * Route notification channels based on identifier type */ public function via(object $notifiable): array { // $notifiable->type comes from SecureOtpService::send($identifier, $type) return match ($notifiable->type) { 'sms' => ['vonage'], // Phone via SMS 'email' => ['mail'], // Email 'whatsapp' => ['whatsapp'], // WhatsApp (if configured) default => ['mail'], // Fallback to email }; } /** * SMS notification */ public function toVonage(object $notifiable): VonageSmsMessage { return (new VonageSmsMessage) ->content("Your verification code is: {$this->code}"); } /** * Email notification */ public function toMail(object $notifiable): MailMessage { return (new MailMessage) ->subject('Your Verification Code') ->line("Your verification code is: {$this->code}") ->line('This code will expire in ' . config('secure-otp.expiry_minutes', 5) . ' minutes.'); } }
Register Your Notification:
Update your .env:
OTP_NOTIFICATION_CLASS="App\Notifications\MultiChannelOtpNotification"
Usage:
// Sends via Vonage SMS $otp->send('01700000000', 'sms'); // Sends via Email $otp->send('user@example.com', 'email'); // Sends via WhatsApp (if configured) $otp->send('+8801700000000', 'whatsapp');
Cleanup Expired OTPs
The package requires scheduled cleanup to remove expired OTP records from the database.
Step 1: Add to Laravel Scheduler
In Laravel 11+, add to routes/console.php:
use Illuminate\Support\Facades\Schedule; Schedule::command('secure-otp:clean --force') ->daily() ->withoutOverlapping() ->onOneServer();
Or in app/Console/Kernel.php (Laravel 10 and below):
protected function schedule(Schedule $schedule) { $schedule->command('secure-otp:clean --force') ->daily() ->withoutOverlapping() ->onOneServer(); }
Step 2: Ensure Cron is Running
Make sure your server has the Laravel scheduler cron job configured:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Manual Cleanup (Optional)
# In development (prompts for confirmation) php artisan secure-otp:clean # In production (bypasses confirmation) php artisan secure-otp:clean --force
Programmatic Cleanup (Advanced)
use Biponix\SecureOtp\Services\SecureOtpService; $deleted = app(SecureOtpService::class)->cleanupExpired(); // Returns the number of deleted records
Security Features
1. HMAC-Based Storage (Rainbow Table Protection)
OTP codes are hashed using HMAC-SHA256 with a secret key before storage. This prevents rainbow table attacks even if the database is compromised. Plain codes are never saved.
// Configure in .env OTP_HASH_SECRET=your-secret-key // Falls back to APP_KEY if not set
2. Timing-Safe Comparison
Uses hash_equals() to prevent timing attacks during verification.
3. Context-Aware Rate Limiting (Brute Force Protection)
Separate rate limits for generation vs verification to balance security and user experience:
Generation (Sending OTP)
- Per Identifier: 3 attempts/hour (prevents spam to a user)
- Per IP: 10 attempts/hour (prevents mass spamming from one IP)
Verification (Checking OTP)
- Per Identifier: 5 attempts/minute (more lenient, user may typo)
- Per IP: 20 attempts/minute (prevents distributed brute force across multiple accounts)
Key Features:
- Smart Detection: IP rate limiting automatically skipped in queue/console contexts
- Flexible Configuration: Supports context-specific overrides (
verify_per_identifier) or falls back to shared config - Cache Key Isolation: Uses context-aware keys (e.g.,
secure-otp:verify:identifier:user@example.com) - Per-Axis Control: Each rate limiting axis can be disabled independently by setting to
nullorfalse
4. Generic Responses
Returns boolean values instead of detailed error messages to prevent enumeration attacks.
5. Race Condition Protection
Uses distributed cache locks (Cache::lock()) combined with database transactions and row-level locks (lockForUpdate()) to serialize OTP generation and ensure only one valid OTP exists per identifier at any time. Lock timeouts (3 seconds) provide friendly error messages under high concurrency.
6. Replay Attack Prevention
Previous OTPs are automatically invalidated when a new one is generated.
7. Attempt Limiting
Maximum verification attempts per OTP (default: 3) to prevent brute force attacks.
8. Security Logging with Privacy
Logs all security events (invalid codes, rate limits, etc.) with PII masking:
- Emails:
te***@example.com - Phones:
***7890
API Reference
generate(string $identifier, ?string $type = null): string
Generates an OTP code without sending it (for custom delivery methods).
Parameters:
$identifier(string): Email, phone, username, or any identifier$type(string|null): Optional. Identifier type for validation/normalization (e.g., 'email', 'sms', 'username')
Returns:
string: The generated OTP code
Throws:
RateLimitExceededException: If rate limit is exceededInvalidIdentifierException: If security check fails or type validation failsOtpGenerationException: If OTP generation fails
Examples:
try { $code = $otp->generate('user@example.com', 'email'); // With validation $code = $otp->generate('01700000000'); // Without validation } catch (RateLimitExceededException $e) { // Handle rate limiting: $e->getRetryAfter() gives seconds until retry }
send(string $identifier, ?string $type = null): void
Generates and queues an OTP notification to the given identifier (non-blocking).
Parameters:
$identifier(string): Email, phone, username, or any identifier$type(string|null): Optional. Identifier type for validation/normalization
Returns:
void
Throws:
RateLimitExceededException: If rate limit is exceededInvalidIdentifierException: If security check fails or type validation failsOtpGenerationException: If OTP generation/sending fails
Examples:
try { $otp->send('user@example.com', 'email'); // Email with validation $otp->send('01700000000', 'sms'); // Phone with SMS type $otp->send('username123'); // No validation } catch (RateLimitExceededException $e) { // Return HTTP 429 with retry_after header }
sendNow(string $identifier, ?string $type = null): void
Generates and sends an OTP synchronously to the given identifier (blocks until sent).
Parameters:
$identifier(string): Email, phone, username, or any identifier$type(string|null): Optional. Identifier type for validation/normalization
Returns:
void
Throws:
RateLimitExceededException: If rate limit is exceededInvalidIdentifierException: If security check fails or type validation failsOtpGenerationException: If OTP generation/sending fails
verify(string $identifier, string $code, ?string $type = null): bool
Verifies an OTP code for the given identifier.
Parameters:
$identifier(string): Email, phone, username, or any identifier$code(string): The OTP code to verify (default 6 digits)$type(string|null): Optional. Must match the type used insend()for normalization consistency
Returns:
true: OTP verified successfullyfalse: Verification failed (invalid, expired, max attempts exceeded, etc.)
Important: The $type parameter must match what was used when sending the OTP to ensure proper normalization.
Examples:
$verified = $otp->verify('user@example.com', '123456', 'email'); $verified = $otp->verify('01700000000', '123456', 'sms'); // Same type as send()
addType(string $name, OtpIdentifierType $type): void
Register a custom identifier type for validation and normalization.
Parameters:
$name(string): Type name (e.g., 'sms', 'email', 'username')$type(OtpIdentifierType): Type implementation
Example:
SecureOtpService::addType('sms', new BangladeshSmsType());
cleanupExpired(): int
Deletes expired OTP records older than configured hours.
Returns: Number of deleted records
Testing
The package includes comprehensive tests:
composer test
Run tests with coverage (requires PCOV or Xdebug):
composer test-coverage
The package maintains 100% code coverage with 103 comprehensive tests covering all security features, edge cases, and error scenarios.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Contributions are welcome! Please see CONTRIBUTING for details.
Security Vulnerabilities
If you discover a security vulnerability, please send an email to ashiquzzaman33@gmail.com. All security vulnerabilities will be promptly addressed.
Credits
License
The MIT License (MIT). Please see License File for more information.