Laravel package for implementing exponential lockout on failed authentication attempts with configurable contexts
joe-nassar-tech/laravel-exponential-lockout is a Laravel package for laravel package for implementing exponential lockout on failed authentication attempts with configurable contexts.
It currently has 1 GitHub stars and 11 downloads on Packagist (latest version 1.4.6).
Install it with composer require joe-nassar-tech/laravel-exponential-lockout.
Discover more Laravel packages by joe-nassar-tech
or browse all Laravel packages to compare alternatives.
Last updated
A comprehensive Laravel package for implementing exponential lockout functionality on failed authentication attempts with configurable contexts and response handling.
login, otp, admin, pin, etc.Install via Composer:
composer require joe-nassar-tech/laravel-exponential-lockout
Publish the configuration file:
php artisan vendor:publish --tag=exponential-lockout-config
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 templates for inheritance
'context_templates' => [
'strict' => [
'enabled' => true,
'min_attempts' => 1, // Lock immediately after 1st failure
'delays' => [300, 900, 1800, 7200, 21600], // 5min β 15min β 30min β 2hr β 6hr
'reset_after_hours' => 48, // Keep attempts longer
],
'api' => [
'enabled' => true,
'response_mode' => 'json',
'min_attempts' => 3,
'delays' => [60, 300, 900, 1800, 7200],
'reset_after_hours' => 24,
],
'mfa' => [
'enabled' => true,
'min_attempts' => 2, // Stricter for MFA
'delays' => [30, 60, 120, 300, 600], // Quick cycles for time-sensitive MFA
'reset_after_hours' => 12, // Reset faster for MFA
],
],
// Context-specific configurations
'contexts' => [
'login' => [
'extends' => 'api', // Inherit API template
'key' => 'email',
'redirect_route' => 'login',
],
'otp' => [
'extends' => 'mfa', // Inherit MFA template
'key' => 'phone',
'response_mode' => 'json',
],
'admin' => [
'extends' => 'strict', // Inherit strict template
'key' => 'email',
'redirect_route' => 'admin.login',
],
// ... more contexts
],
];
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 |
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');
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);
}
}
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);
}
}
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],
],
],
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 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);
}
$info = Lockout::getLockoutInfo('login', $email);
/*
Returns:
[
'context' => 'login',
'key' => '[email protected]',
'attempts' => 3,
'is_locked_out' => true,
'remaining_time' => 840,
'locked_until' => Carbon instance,
'last_attempt' => Carbon instance,
]
*/
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
# Clear lockout for specific context and key
php artisan lockout:clear login [email protected]
# Clear with force (no confirmation)
php artisan lockout:clear login [email protected] --force
# Clear all lockouts for a context
php artisan lockout:clear login --all
# With force flag
php artisan lockout:clear login --all --force
// 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
Each context can be configured independently or inherit from templates:
'context_templates' => [
'strict' => [
'enabled' => true,
'min_attempts' => 1, // Lock immediately after 1st failure
'delays' => [300, 900, 1800, 7200, 21600], // 5min β 15min β 30min β 2hr β 6hr
'reset_after_hours' => 48, // Keep attempts longer
],
'api' => [
'enabled' => true,
'response_mode' => 'json',
'min_attempts' => 3,
'delays' => [60, 300, 900, 1800, 7200],
'reset_after_hours' => 24,
],
],
'contexts' => [
'login' => [
'extends' => 'api', // Inherit API template
'key' => 'email',
'redirect_route' => 'login', // Override specific setting
],
'admin' => [
'extends' => 'strict', // Inherit strict template
'key' => 'email',
'redirect_route' => 'admin.login',
],
],
'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)
'min_attempts' => 3, // Attempts before first lockout
'reset_after_hours' => 24, // Reset attempts after inactivity
],
],
email - Extract from email input fieldphone - Extract from phone input fielduser_id - Extract from authenticated user IDip - Use client IP addressusername - Extract from username input fieldauto - Auto-detect JSON or redirect based on requestjson - Always return JSON responseredirect - Always redirect to specified routecallback - Use custom callback functionDefault 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
],
],
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());
}
Configure the cache store in your config:
'cache' => [
'store' => 'redis', // Use specific store
'prefix' => 'app_lockout',
],
Cache entries automatically expire after lockout duration + 1 hour buffer.
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
],
],
The package includes comprehensive test coverage. Run tests with:
composer test
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' => '[email protected]', 'password' => 'wrong']);
}
// Verify lockout is active
$this->assertTrue(Lockout::isLockedOut('login', '[email protected]'));
// Test lockout response
$response = $this->post('/login', ['email' => '[email protected]', 'password' => 'correct']);
$response->assertStatus(429);
}
}
Lockouts not working:
Lockouts not clearing:
Wrong response format:
response_mode in context configEnable debug logging:
// In your controller
Log::info('Lockout status', [
'context' => 'login',
'key' => $email,
'info' => Lockout::getLockoutInfo('login', $email)
]);
Contributions are welcome! Please see CONTRIBUTING.md for details.
This package is open-sourced software licensed under the MIT license.
See CHANGELOG.md for version history and updates.
Joe Nassar
Email: [email protected]
Made with β€οΈ for the Laravel community