joe-nassar-tech / laravel-exponential-lockout
Laravel package for implementing exponential lockout on failed authentication attempts with configurable contexts
Requires
- php: ^8.0
- illuminate/cache: ^9.0|^10.0|^11.0|^12.0
- illuminate/console: ^9.0|^10.0|^11.0|^12.0
- illuminate/http: ^9.0|^10.0|^11.0|^12.0
- illuminate/support: ^9.0|^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^7.0|^8.0|^9.0|^10.0
- phpunit/phpunit: ^9.0|^10.0|^11.0
README
A comprehensive Laravel package for implementing exponential lockout functionality on failed authentication attempts with configurable contexts and response handling.
Features
- 🎯 Perfect Exponential Lockout: Grace attempts system - exactly 1 attempt allowed after each lockout period
- ✅ 100% Automatic Middleware: Zero code changes needed - just add middleware to routes
- ✅ Smart Delay Progression: Configurable delays (default: 1min → 5min → 15min → 30min → 2hr → 6hr → 12hr → 24hr)
- ✅ Configurable Free Attempts: Set how many attempts before first lockout (default: 3)
- ✅ Multiple Contexts: Different rules for
login
,otp
,admin
,pin
, etc. - ✅ Flexible Key Extraction: Track by email, phone, username, IP, or custom logic
- ✅ Auto-Detection: Automatically detects 4xx/5xx failures and 2xx success
- ✅ Manual API Control: Full programmatic control when needed
- ✅ Smart Response Handling: Auto-detect JSON/redirect responses with proper headers
- ✅ Persistent Attempt History: Remembers failures across lockout periods
- ✅ Cache-Based Storage: Uses Laravel's cache system (Redis, File, Database, etc.)
- ✅ Artisan Commands: CLI tools for lockout management and debugging
- ✅ Blade Directives: Template helpers for lockout status display
- ✅ Laravel 9-12+ Compatible: Full support for all modern Laravel versions
Installation
Install via Composer:
composer require joe-nassar-tech/laravel-exponential-lockout
Publish the configuration file:
php artisan vendor:publish --tag=exponential-lockout-config
Configuration
The package comes with sensible defaults, but you can customize everything in config/exponential-lockout.php
:
return [ // Cache configuration 'cache' => [ 'store' => null, // Uses default cache store 'prefix' => 'exponential_lockout', ], // Default delay sequence (in seconds) - 1min → 5min → 15min → 30min → 2hr → 6hr → 12hr → 24hr 'default_delays' => [60, 300, 900, 1800, 7200, 21600, 43200, 86400], // Response handling 'default_response_mode' => 'auto', // 'auto', 'json', 'redirect', 'callback' 'default_redirect_route' => 'login', // Context-specific configurations 'contexts' => [ 'login' => [ 'enabled' => true, 'key' => 'email', 'delays' => null, // Uses default_delays 'min_attempts' => 3, // Lock after 3 failed attempts (allow 2 free attempts) 'reset_after_hours' => 24, // Reset attempt count after 24 hours ], 'otp' => [ 'enabled' => true, 'key' => 'phone', 'delays' => [30, 60, 180, 300, 600], // Shorter delays for OTP 'response_mode' => 'json', 'min_attempts' => 3, // Lock after 3 failed attempts (allow 2 free attempts) ], // ... more contexts ], ];
How It Works
🎯 Perfect Exponential Lockout Behavior
The package implements a grace attempt system that provides exactly 1 attempt after each lockout period:
Attempt | Result | Behavior |
---|---|---|
1st-3rd ❌ | ✅ Free attempts | No lockout (configurable with min_attempts ) |
4th ❌ | 🚫 Block 60s | First lockout (using default delays) |
After 60s | 🎁 1 grace attempt | Exactly 1 try allowed |
Grace ❌ | 🚫 Block 300s | Second lockout (5 minutes) |
After 300s | 🎁 1 grace attempt | Exactly 1 try allowed |
Grace ❌ | 🚫 Block 900s | Third lockout (15 minutes) |
Any Success ✅ | 🔄 Complete Reset | Back to 3 free attempts |
🔑 Key Features:
- Configurable free attempts (default: 3) before first lockout
- Progressive delays that increase exponentially
- Grace attempts - exactly 1 attempt allowed after each lockout expires
- Automatic reset on any successful authentication
- Persistent memory - remembers attempt history across sessions
Basic Usage
1. Middleware Protection
Protect routes with middleware:
use Illuminate\Support\Facades\Route; // Login route protection Route::post('/login', [LoginController::class, 'login']) ->middleware('exponential.lockout:login'); // OTP verification protection Route::post('/verify-otp', [OtpController::class, 'verify']) ->middleware('exponential.lockout:otp'); // PIN validation protection Route::post('/validate-pin', [PinController::class, 'validate']) ->middleware('exponential.lockout:pin');
2. Manual Lockout Management
Use the Lockout
facade for manual control:
use ExponentialLockout\Facades\Lockout; class LoginController extends Controller { public function login(Request $request) { // Check if locked out (optional - middleware handles this automatically) if (Lockout::isLockedOut('login', $request->email)) { $remaining = Lockout::getRemainingTime('login', $request->email); return response()->json(['error' => 'Locked', 'retry_after' => $remaining], 429); } $credentials = $request->only('email', 'password'); if (Auth::attempt($credentials)) { // Clear lockout on successful login (optional - middleware does this automatically) Lockout::clear('login', $request->email); return response()->json(['success' => true], 200); } // Record failed attempt (optional - middleware does this automatically) Lockout::recordFailure('login', $request->email); return response()->json(['error' => 'Invalid credentials'], 401); } }
3. OTP Verification Example
class OtpController extends Controller { public function verify(Request $request) { $phone = $request->input('phone'); $otp = $request->input('otp'); if ($this->isValidOtp($phone, $otp)) { // Clear lockout on successful verification Lockout::clear('otp', $phone); return response()->json(['message' => 'OTP verified successfully']); } // Record failed attempt Lockout::recordFailure('otp', $phone); return response()->json([ 'error' => 'Invalid OTP', 'attempts' => Lockout::getAttemptCount('otp', $phone) ], 401); } }
Advanced Usage
Custom Key Extraction
Define custom key extractors in the config:
'key_extractors' => [ 'user_session' => function ($request) { return $request->session()->getId(); }, 'device_fingerprint' => function ($request) { return hash('sha256', $request->userAgent() . $request->ip()); }, ], 'contexts' => [ 'admin_login' => [ 'key' => 'device_fingerprint', 'delays' => [300, 900, 1800, 7200], ], ],
Custom Response Handling
Implement custom response logic:
'custom_response_callback' => function ($context, $key, $remainingTime) { return response()->json([ 'error' => 'Account temporarily locked', 'context' => $context, 'retry_after' => $remainingTime, 'retry_after_human' => gmdate('H:i:s', $remainingTime), ], 429); },
Check Lockout Status
Check if a user is locked out before processing:
if (Lockout::isLockedOut('login', $email)) { $remainingTime = Lockout::getRemainingTime('login', $email); return response()->json([ 'error' => 'Account locked', 'retry_after' => $remainingTime ], 429); }
Get Detailed Lockout Information
$info = Lockout::getLockoutInfo('login', $email); /* Returns: [ 'context' => 'login', 'key' => 'user@example.com', 'attempts' => 3, 'is_locked_out' => true, 'remaining_time' => 840, 'locked_until' => Carbon instance, 'last_attempt' => Carbon instance, ] */
Blade Directives
Use Blade directives in your templates:
{{-- Check if user is locked out --}} @lockout('login', $user->email) <div class="alert alert-warning"> Your account is temporarily locked. Please try again later. </div> @endlockout {{-- Show content when NOT locked out --}} @notlockout('login', $user->email) <form method="POST" action="/login"> <!-- Login form --> </form> @endnotlockout {{-- Get lockout information --}} @lockoutinfo($lockoutInfo, 'login', $user->email) @if($lockoutInfo['is_locked_out']) <p>Locked for {{ gmdate('H:i:s', $lockoutInfo['remaining_time']) }} more</p> @endif {{-- Get remaining time --}} @lockouttime($remainingSeconds, 'login', $user->email) @if($remainingSeconds > 0) <p>Try again in {{ $remainingSeconds }} seconds</p> @endif
Artisan Commands
Clear Specific Lockout
# Clear lockout for specific context and key php artisan lockout:clear login user@example.com # Clear with force (no confirmation) php artisan lockout:clear login user@example.com --force
Clear All Lockouts for Context
# Clear all lockouts for a context php artisan lockout:clear login --all # With force flag php artisan lockout:clear login --all --force
API Reference
Lockout Facade Methods
// Record a failed attempt Lockout::recordFailure(string $context, string $key): int // Check if locked out Lockout::isLockedOut(string $context, string $key): bool // Get remaining lockout time in seconds Lockout::getRemainingTime(string $context, string $key): int // Clear lockout Lockout::clear(string $context, string $key): bool // Clear all lockouts for context Lockout::clearContext(string $context): bool // Get attempt count Lockout::getAttemptCount(string $context, string $key): int // Extract key from request Lockout::extractKeyFromRequest(string $context, Request $request): string // Get detailed lockout information Lockout::getLockoutInfo(string $context, string $key): array
Context Configuration
Each context can be configured independently:
'contexts' => [ 'login' => [ 'enabled' => true, // Enable/disable this context 'key' => 'email', // Key extraction method 'delays' => [60, 300, 900], // Custom delay sequence 'response_mode' => 'auto', // Response handling mode 'redirect_route' => 'login', // Redirect route for web requests 'max_attempts' => null, // Max attempts (null = use delay sequence length) ], ],
Available Key Extractors
email
- Extract fromemail
input fieldphone
- Extract fromphone
input fielduser_id
- Extract from authenticated user IDip
- Use client IP addressusername
- Extract fromusername
input field- Custom callable - Define your own extraction logic
Response Modes
auto
- Auto-detect JSON or redirect based on requestjson
- Always return JSON responseredirect
- Always redirect to specified routecallback
- Use custom callback function
Delay Sequences
Default sequence provides exponential backoff:
[60, 300, 900, 1800, 7200, 21600, 43200, 86400] // 1min, 5min, 15min, 30min, 2hr, 6hr, 12hr, 24hr
Customize per context:
'contexts' => [ 'otp' => [ 'delays' => [30, 60, 180, 300, 600], // Shorter for OTP ], 'admin' => [ 'delays' => [600, 1800, 7200, 21600], // Longer for admin ], ],
Error Handling
The package includes comprehensive error handling:
try { Lockout::recordFailure('invalid_context', $key); } catch (InvalidArgumentException $e) { // Context not configured or disabled Log::error('Lockout error: ' . $e->getMessage()); }
Cache Considerations
Cache Store Selection
Configure the cache store in your config:
'cache' => [ 'store' => 'redis', // Use specific store 'prefix' => 'app_lockout', ],
TTL Management
Cache entries automatically expire after lockout duration + 1 hour buffer.
Redis Optimization
For Redis, consider using a dedicated database:
// config/cache.php 'stores' => [ 'lockout_redis' => [ 'driver' => 'redis', 'connection' => 'lockout', ], ], // config/database.php 'redis' => [ 'lockout' => [ 'host' => env('REDIS_HOST', '127.0.0.1'), 'database' => 2, // Dedicated database ], ],
Testing
The package includes comprehensive test coverage. Run tests with:
composer test
Testing Lockouts in Your App
class LoginTest extends TestCase { public function test_user_gets_locked_out_after_failures() { // Simulate multiple failed attempts for ($i = 0; $i < 3; $i++) { $this->post('/login', ['email' => 'test@example.com', 'password' => 'wrong']); } // Verify lockout is active $this->assertTrue(Lockout::isLockedOut('login', 'test@example.com')); // Test lockout response $response = $this->post('/login', ['email' => 'test@example.com', 'password' => 'correct']); $response->assertStatus(429); } }
Performance Considerations
- Cache Efficiency: Uses single cache key per context/user combination
- TTL Optimization: Automatic cleanup of expired lockouts
- Memory Usage: Minimal data storage per lockout entry
- Lookup Speed: O(1) cache lookups for lockout status
Security Best Practices
- Rate Limiting: Combine with Laravel's rate limiting for comprehensive protection
- IP Tracking: Use IP-based lockouts for anonymous endpoints
- Context Separation: Use different contexts for different authentication methods
- Cache Security: Secure your cache store (Redis AUTH, etc.)
- Key Hashing: Keys are automatically hashed for privacy
Troubleshooting
Common Issues
Lockouts not working:
- Check context is enabled in config
- Verify cache store is working
- Ensure middleware is applied to routes
Lockouts not clearing:
- Check cache connectivity
- Verify context and key match exactly
- Use Artisan command to manually clear
Wrong response format:
- Check
response_mode
in context config - Verify request headers for JSON detection
- Test with custom response callback
Debug Mode
Enable debug logging:
// In your controller Log::info('Lockout status', [ 'context' => 'login', 'key' => $email, 'info' => Lockout::getLockoutInfo('login', $email) ]);
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for details.
License
This package is open-sourced software licensed under the MIT license.
Changelog
See CHANGELOG.md for version history and updates.
Support
- Documentation: This README and inline code comments
- Issues: GitHub Issues for bug reports and feature requests
- Discussions: GitHub Discussions for questions and community support
About the Developer
Joe Nassar
Email: joe.nassar.tech@gmail.com
Made with ❤️ for the Laravel community