Custos

Password

Email and password authentication with Argon2 hashing

Password Plugin

The Password plugin provides classic email + password authentication. Passwords are hashed with Argon2id, which is resistant to GPU and ASIC brute-force attacks.

Setup

Add the plugin to your model:

class User < ApplicationRecord
  include Custos::Authenticatable

  custos do
    plugin :password
  end
end

Run the model generator to create the necessary migration:

rails generate custos:model User password
rails db:migrate

This adds a password_digest column to your users table.

Configuration Options

Pass options when enabling the plugin:

custos do
  plugin :password,
    min_length: 12,
    max_length: 128,
    require_uppercase: true,
    require_digit: true,
    require_special: true
end
OptionTypeDefaultDescription
min_lengthInteger8Minimum password length
max_lengthInteger128Maximum password length
require_uppercaseBooleanfalseRequire at least one uppercase letter
require_digitBooleanfalseRequire at least one digit
require_specialBooleanfalseRequire at least one special character
t_costIntegerArgon2 defaultArgon2 time cost (iterations)
m_costIntegerArgon2 defaultArgon2 memory cost (2^m_cost KiB)
p_costIntegerArgon2 defaultArgon2 parallelism factor

All validations use standard Active Model validations, so errors appear on record.errors as you'd expect.

Argon2 Tuning

The default Argon2 parameters work well for most applications. If you need to tune memory usage (for example, to limit DoS risk on memory-constrained servers), pass Argon2 cost parameters:

custos do
  plugin :password,
    t_cost: 2,    # iterations (higher = slower)
    m_cost: 16,   # memory: 2^16 = 64 MiB (default)
    p_cost: 1     # parallelism
end

Higher m_cost values provide better security but use more memory per hash operation. On shared or low-memory hosts, consider reducing m_cost and increasing t_cost to compensate.

Instance Methods

password=(plain_password)

Sets the password. Hashes the plain-text password with Argon2 and stores the result in password_digest.

user = User.new(email: "alice@example.com")
user.password = "SecurePassword123!"
user.save!

authenticate_password(plain_password)

Verifies a plain-text password against the stored digest. Returns true or false.

user.authenticate_password("SecurePassword123!")
# => true

user.authenticate_password("wrong")
# => false

If the Lockout plugin is also enabled, authenticate_password automatically records failed attempts and resets the counter on success.

Class Methods

find_by_email_and_password(email:, password:)

Finds a user by email and verifies the password in one call. Returns the user record on success, nil on failure.

user = User.find_by_email_and_password(
  email: "alice@example.com",
  password: "SecurePassword123!"
)

If the account is locked (Lockout plugin enabled), this method returns nil without attempting password verification.

Controller Example

class SessionsController < ApplicationController
  def create
    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

Database Schema

The Password plugin adds one column to your model's table:

ColumnTypeNotes
password_digeststringArgon2id hash

Security Notes

  • Passwords are hashed with Argon2id, the winner of the Password Hashing Competition
  • The @password instance variable is cleared automatically after save -- plain-text passwords are not kept in memory
  • Password verification includes timing-safe comparison (built into Argon2)
  • find_by_email_and_password performs a dummy Argon2 verify when no user is found, preventing timing-based user enumeration
  • Corrupted password digests are handled gracefully (returns false, not an error)
  • When combined with Lockout, accounts are protected against brute-force attacks via the after_authentication event hook

On this page