Custos

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
end

Run the model generator:

rails generate custos:model User password lockout
rails db:migrate

This 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
OptionTypeDefaultDescription
max_attemptsInteger3Failed attempts before lockout
lockout_durationInteger or nil1800 (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 locked

reset_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 → nil

unlock!

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:

  1. User.find_by_email_and_password checks locked? before attempting verification. If locked, returns nil.
  2. On failed password verification, the hook triggers record_failed_attempt! (uses atomic SQL increment).
  3. 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
end

Showing 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
end

MFA 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
OptionTypeDefaultDescription
max_mfa_attemptsInteger5Failed MFA attempts before lockout
mfa_lockout_durationIntegerSame as lockout_durationMFA lockout duration in seconds

MFA Instance Methods

  • mfa_locked? -- returns true if MFA is currently locked
  • record_failed_mfa_attempt! -- increments the MFA failure counter atomically
  • reset_failed_mfa_attempts! -- resets the MFA counter on successful verification

Database Columns

Added to your model's table:

ColumnTypeDefaultNotes
failed_auth_countinteger0Tracks consecutive password failures
locked_atdatetimenilWhen the account was locked
failed_mfa_countinteger0Tracks consecutive MFA failures
mfa_locked_atdatetimenilWhen MFA was locked

Security Notes

  • Lockout is time-based by default (30 minutes). Set lockout_duration: nil for 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.

On this page