Lockout
Account lockout after failed authentication attempts
Lockout Plugin
The Lockout plugin protects accounts from brute-force attacks by locking them after a configurable number of failed authentication attempts.
Setup
class User < ApplicationRecord
include Custos::Authenticatable
custos do
plugin :password
plugin :lockout
end
endRun the model generator:
rails generate custos:model User password lockout
rails db:migrateThis adds failed_auth_count, locked_at, failed_mfa_count, and mfa_locked_at columns to your model's table.
Configuration Options
custos do
plugin :lockout,
max_attempts: 5,
lockout_duration: 60 * 60 # 1 hour
end| Option | Type | Default | Description |
|---|---|---|---|
max_attempts | Integer | 3 | Failed attempts before lockout |
lockout_duration | Integer or nil | 1800 (30 min) | Lockout duration in seconds. nil = permanent |
Instance Methods
locked?
Returns true if the account is currently locked. If lockout_duration is set, the lock expires automatically after the specified period.
user.locked?
# => true (if locked and within lockout duration)
# => false (if lock has expired or account was never locked)record_failed_attempt!
Increments the failed attempt counter. If the counter reaches max_attempts, the account is locked immediately.
user.record_failed_attempt!
# failed_auth_count goes from 2 to 3 → account lockedreset_failed_attempts!
Resets the counter and clears the lock. Called automatically by the Password plugin on successful authentication.
user.reset_failed_attempts!
# failed_auth_count → 0, locked_at → nilunlock!
Manually unlocks the account and resets the counter. Use this for admin-initiated unlocks.
user.unlock!Integration with Password Plugin
When both Password and Lockout plugins are enabled, they work together automatically through an internal event hook. The Lockout plugin subscribes to the :after_authentication hook fired by the Password plugin:
User.find_by_email_and_passwordcheckslocked?before attempting verification. If locked, returnsnil.- On failed password verification, the hook triggers
record_failed_attempt!(uses atomic SQL increment). - On successful verification, the hook triggers
reset_failed_attempts!.
No additional code is needed in your controllers.
custos do
plugin :password
plugin :lockout, max_attempts: 5, lockout_duration: 15 * 60
end
# This handles lockout automatically:
user = User.find_by_email_and_password(email: "alice@example.com", password: "wrong")
# => nil (and failed_auth_count incremented)Controller Example
Admin Unlock
class Admin::UsersController < ApplicationController
before_action :require_admin
def unlock
user = User.find(params[:id])
user.unlock!
redirect_to admin_user_path(user), notice: "Account unlocked."
end
endShowing Lock Status
class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.locked?
flash[:alert] = "Account is temporarily locked. Please try again later."
render :new, status: :unprocessable_entity
return
end
user = User.find_by_email_and_password(
email: params[:email],
password: params[:password]
)
if user
_session, token = Custos::SessionManager.create(user, request: request)
cookies.signed[:custos_session_token] = {
value: token,
httponly: true,
secure: Rails.env.production?
}
redirect_to dashboard_path
else
flash[:alert] = "Invalid email or password."
render :new, status: :unprocessable_entity
end
end
endMFA Lockout
When both Lockout and MFA plugins are enabled, failed MFA attempts are tracked independently via the :after_mfa_verification hook.
custos do
plugin :mfa
plugin :lockout,
max_attempts: 5,
max_mfa_attempts: 5,
mfa_lockout_duration: 15 * 60 # 15 minutes
end| Option | Type | Default | Description |
|---|---|---|---|
max_mfa_attempts | Integer | 5 | Failed MFA attempts before lockout |
mfa_lockout_duration | Integer | Same as lockout_duration | MFA lockout duration in seconds |
MFA Instance Methods
mfa_locked?-- returnstrueif MFA is currently lockedrecord_failed_mfa_attempt!-- increments the MFA failure counter atomicallyreset_failed_mfa_attempts!-- resets the MFA counter on successful verification
Database Columns
Added to your model's table:
| Column | Type | Default | Notes |
|---|---|---|---|
failed_auth_count | integer | 0 | Tracks consecutive password failures |
locked_at | datetime | nil | When the account was locked |
failed_mfa_count | integer | 0 | Tracks consecutive MFA failures |
mfa_locked_at | datetime | nil | When MFA was locked |
Security Notes
- Lockout is time-based by default (30 minutes). Set
lockout_duration: nilfor permanent lockout requiring manual admin intervention. - The counter resets to zero on successful authentication, so legitimate users are never impacted by a single failed attempt in the past.
- Failed attempt tracking uses atomic SQL (
UPDATE SET ... CASE WHEN) to prevent race conditions under concurrent requests. - When a lockout expires, the counter resets on the next failed attempt instead of continuing from the previous value.
- Consider showing a generic "Invalid email or password" message instead of "Account locked" to avoid revealing account existence.