Digging Deeper / Rodauth
A quick-reference guide to Rodauth's architecture, internals, customization, and minimal setup.
Table of Contents ↥
How Rodauth Interacts with Roda ↥
The Plugin Hook
Rodauth is a Roda plugin. Loading it is one line:
plugin :rodauth do
# configuration...
end
Under the hood, Roda::RodaPlugins.register_plugin(:rodauth, Rodauth) makes Roda recognize the :rodauth plugin name. Roda then calls Rodauth.configure and mixes in three modules:
| Module | What it adds |
|---|---|
ClassMethods | .rodauth, .precompile_rodauth_templates |
InstanceMethods | #rodauth — access the auth instance |
RequestMethods | #rodauth inside route blocks (dispatcher) |
The Route Dispatch Flow
Browser request
│
▼
Roda#route block
│
▼
r.rodauth ← calls Rodauth::RequestMethods#rodauth
│
▼
Rodauth::Auth#route!
│
▼
Looks up path in route_hash (O(1) lookup)
│
▼
Finds handle_<feature>_route method
│
▼
request.is("/login") do ← Roda's path matcher
check_csrf ← CSRF protection
_around_rodauth do ← wraps hooks
before_rodauth ← global before hook
send(_handle_login) ← the actual GET/POST handler
end
end
Key insight: route_hash is a frozen Hash mapping URL paths (e.g., "/login") to handler methods (e.g., :handle_login). It's built once during post_configure, so dispatch is a simple hash lookup.
Automatic Dependencies
When plugin :rodauth loads, it automatically requires Roda plugins you likely need:
:json+:json_parser— if JSON API is enabled:render— for templates (can be disabled):route_csrfor:csrf— CSRF protection:flash— flash messages:h— HTML escaping helper
Rodauth Architecture ↥
Layer Diagram
┌─────────────────────────────────────────────────────┐
│ Your Roda App │
│ │
│ plugin :rodauth do │
│ enable :login, :logout, :otp │
│ login_redirect { '/dashboard' } │
│ template_opts(...) │
│ end │
│ │
│ route do |r| │
│ r.rodauth ← dispatches to Rodauth │
│ r.get 'dashboard' { ... } │
│ end │
├─────────────────────────────────────────────────────┤
│ Rodauth::Auth (subclass) │
│ │
│ Includes: Base + Login + Logout + OTP + ... │
│ Has: route_hash, features list, configuration │
├─────────────────────────────────────────────────────┤
│ Feature Modules (mixin) │
│ │
│ Rodauth::Base — core (always loaded) │
│ Rodauth::Login — login/logout routes │
│ Rodauth::Otp — TOTP two-factor │
│ Rodauth::Remember — remember me cookies │
│ ... 50+ more features │
├─────────────────────────────────────────────────────┤
│ Your Database (Sequel) │
│ │
│ accounts, account_password_reset_keys, │
│ account_otp_keys, account_remember_keys, ... │
└─────────────────────────────────────────────────────┘
The Configuration DSL
plugin :rodauth do
# 1. Set up features (order doesn't matter — deps auto-load)
enable :login, :logout, :reset_password, :otp
# 2. Configure database
db DB
accounts_table :users
password_hash_column :ph
# 3. Set redirects
login_redirect { '/home' }
logout_redirect { '/goodbye' }
# 4. Set params
login_param :email
password_param :pass
# 5. Customize behavior with blocks
already_logged_in { redirect '/' }
set_session_value { |k,v| session[k] = v } # custom session store
# 6. Override any auth method
login_view do
view('login', 'Sign In') # custom title
end
end
The three method categories:
| Category | What they are | Example |
|---|---|---|
auth_value_methods | Configuration values (setters/getters) | login_redirect, db |
auth_methods | Public runtime methods | login, logged_in? |
auth_private_methods | Private runtime methods | _set_password |
All three can be overridden in the configuration block.
Feature Dependencies
Features declare dependencies with depends:
# otp.rb
depends :two_factor_base, :require_domain, :require_hmac_secret
When you enable :otp, Rodauth automatically loads :two_factor_base first, then :otp. You never need to manually list dependencies.
The Auth Instance Lifecycle
Request comes in
│
▼
Roda scope#rodauth(name=nil)
│
├─ nil? → @_rodauth ||= Auth.new(scope)
└─ name? → (@_rodauths ||= {})[name] ||= Auth.new(scope)
│
▼
Auth instance holds:
- @scope (the Roda instance)
- @features (included modules)
- @routes (flat list of all routes)
- @route_hash (path → method mapping)
│
▼
Methods called on auth instance:
- r.rodauth → route! → dispatches to feature handler
- auth.logged_in? → checks session
- auth.login('password') → creates session
Session Architecture
Session (server-side) Cookies (client-side) ┌──────────────────────────┐ ┌──────────────────────────┐ │ account_id → 42 │ │ rack.session → encrypted │ │ authenticated_by → ['pw']│ │ │ │ two_factor_setup → true │ │ remember → "42_hmac_key" │ │ otp_auth → true │ │ │ └──────────────────────────┘ └──────────────────────────┘
- Login → sets
account_idin session - 2FA → appends to
authenticated_byarray - Logout →
clear_session - Remember → sets a signed cookie, auto-login on next visit
Customizing Rodauth Views ↥
Template Resolution Priority
1. Roda render plugin looks in your views/ directory
(e.g., views/login.str)
│
▼ (if not found)
2. Rodauth's built-in templates/
(e.g., templates/login.str)
To override a template: create a file with the same name in your Roda views directory.
Template Composition Pattern
Templates are composable — they call sub-templates via the rodauth local:
<!-- templates/login.str -->
#{rodauth.login_form_header}
#{rodauth.render('login-form')}
#{rodauth.login_form_footer}
<!-- templates/login-form.str -->
<form method="post" class="rodauth" role="form">
#{rodauth.csrf_tag}
#{rodauth.render('login-field')}
#{rodauth.render('password-field')}
#{rodauth.button(rodauth.login_button)}
</form>
<!-- templates/login-field.str -->
<div class="form-group">
#{rodauth.render('login-field-label')}
#{rodauth.render('login-field-input')}
</div>
This means you can override just the input field without touching the form.
Method 1: Override Template Files
# In your Roda app setup
plugin :render, views: 'app/views', engine: 'str'
# Then create:
# app/views/login.str ← overrides login page
# app/views/password-field.str ← overrides password input
# app/views/button.str ← overrides ALL buttons
Method 2: Override View Methods in Config
plugin :rodauth do
# Change the login page title
login_view do
view('login', 'Welcome Back!')
end
# Add extra HTML to login form
login_additional_form_tags do
"<input type='hidden' name='source' value='web' />"
end
end
Method 3: Override Field Rendering
plugin :rodauth do
# Custom HTML attributes for all fields
field_attributes do |field|
'data-custom="value"'
end
# Custom attributes for fields with errors
field_error_attributes do |field|
' aria-invalid="true" class="error"'
end
# Custom error message format
formatted_field_error do |field, error|
"<span class='error-msg'>#{error}</span>"
end
# Suffix all labels (e.g., add a colon)
input_field_label_suffix ':'
end
Method 4: Template Options
plugin :rodauth do
# Pass custom locals to all templates
template_opts locals: { app_name: 'MyApp' }
# Custom layout
template_opts layout_opts: { path: 'app/views/layout.str' }
end
All Built-in Templates
| Template | Used By |
|---|---|
login | Login page |
login-form | Login form partial |
login-field | Login input field |
password-field | Password input field |
button | Submit button |
logout | Logout confirmation |
reset-password-request | Request password reset |
reset-password | New password form |
remember | Remember me settings |
otp-setup | TOTP enrollment |
otp-auth | TOTP code entry |
otp-disable | TOTP removal |
two-factor-manage | 2FA method management |
two-factor-auth | 2FA verification |
change-password | Password change form |
create-account | Account creation form |
verify-account | Account verification |
lockout | Locked account message |
How Features Work Internally ↥
Anatomy of a Feature
Every feature follows this pattern:
module Rodauth
Feature.define(:login) do
# ── Routes ──
route do
# GET /login → show form
# POST /login → process login
end
# ── Views ──
view('login', 'Login')
# ── Redirects ──
redirect('/login') { request.referer || '/' }
# ── Auth Value Methods (configuration) ──
auth_value_methods :login_redirect, :login_param
# ── Auth Methods (runtime) ──
auth_methods :login, :logged_in?
# ── Auth Private Methods ──
auth_private_methods :_login_from_session
# ── Dependencies ──
depends :base
# ── Email ──
email(:login_notice, 'Login Notice')
# ── Session/Flash Keys ──
session_key :login_redirect_key, :login_redirect
flash_key :login_flash_key, :login_notice
# ── Before/After Hooks ──
before :login_route
after :login
# ── Button Text ──
button 'Login', :login_button
# ── Loaded Templates ──
loaded_templates %w[login login-form login-field password-field button]
end
end
The Request Lifecycle (Inside a Route)
1. r.rodauth called
2. Auth#route! looks up path in route_hash
3. handle_login_route runs:
│
├─ request.is("/login") do
│ ├─ check_csrf! (CSRF token verification)
│ ├─ _around_rodauth do
│ │ ├─ before_rodauth (global before hook)
│ │ ├─ before_login_route (feature before hook)
│ │ │
│ │ ├─ _handle_login(request) do
│ │ │ ├─ check_already_logged_in (skip if logged in)
│ │ │ ├─ r.get? → login_view (show form)
│ │ │ └─ r.post? → _login (process credentials)
│ │ │
│ │ ├─ after_login_route (feature after hook)
│ │ └─ after_rodauth (global after hook)
│ └─ end
│
└─ nil ( Roda continues routing if no match)
Login Feature Internals
POST /login
│
▼
1. Find account by login param
│
├─ Not found → set_error_flash, redirect /login
│
▼
2. Check account status
│
├─ Unverified → redirect /verify-account
│
▼
3. Verify password (BCrypt)
│
├─ Invalid → increment invalid_logins, redirect /login
│
▼
4. Call login('password')
│
├─ Save redirect location (session)
├─ Run before_login hook
├─ login_session('password'):
│ ├─ session[:account_id] = account.id
│ └─ session[:authenticated_by] = ['password']
├─ Run after_login hook
└─ Response: redirect with notice flash
Two-Factor Authentication Internals
Normal Login
│
▼
session[authenticated_by] = ['password']
│
▼
┌─────────────────────┐
│ authenticated_by │
│ length >= 2? │
└─────────────────────┘
│ │
Yes No
│ │
▼ ▼
Full Access 2FA Required
│
▼
GET /multifactor-auth
│
┌─────────────┼──────────────┐
▼ ▼ ▼
TOTP Code Recovery Code SMS Code
│ │ │
▼ ▼ ▼
two_factor_authenticate('totp')
│
▼
session[:authenticated_by] << 'totp'
│
▼
Full Access (length >= 2)
Key session keys for 2FA:
| Session Key | Purpose |
|---|---|
authenticated_by | Array of auth methods used |
two_factor_setup | Whether 2FA was fully set up |
two_factor_auth_failures | Failed 2FA attempt counter |
otp_auth | Whether TOTP was verified |
otp_setup | Whether TOTP key was stored |
Remember Me Internals
Login with "Remember Me" checked
│
▼
Generate remember_key (random token)
│
├─ Store in DB: account_remember_keys
├─ Set cookie: "account_id_hmac(signed_key)"
│
▼
Next visit (not logged in)
│
▼
rodauth.load_memory (in route block)
│
├─ Parse cookie → extract account_id + key
├─ Verify HMAC signature
├─ Look up key in DB
│
├─ Valid → auto-login, delete old key, set new key
└─ Invalid → ignore cookie
Password Reset Internals
1. GET /reset-password-request
└─ User enters login
2. POST /reset-password-request
├─ Find account
├─ Generate reset_key (random)
├─ Store in DB: account_password_reset_keys
│ ├─ key: hmac_signed(reset_key)
│ └─ deadline: now + 1 day
└─ Send email with link:
/reset-password?key=account_id_hmac(signed_key)
3. GET /reset-password?key=...
├─ Store key in session (security: don't expose in URL)
└─ Redirect to /reset-password
4. GET /reset-password
├─ Look up key in session
├─ Validate key exists in DB, not expired
└─ Show new password form
5. POST /reset-password
├─ Validate key still valid
├─ Check password requirements
├─ Update password hash in DB
├─ Delete reset key (single-use)
└─ Redirect to login with success flash
Hook System
Every feature supports before and after hooks at two levels:
Global hooks (from base):
before_rodauth / after_rodauth — runs for ALL routes
Feature hooks (per-route):
before_login_route / after_login_route
before_login / after_login
Override in config:
plugin :rodauth do
before :login_route do
# runs before GET/POST /login
audit_log_attempt(request.params)
end
after :login do
# runs after successful login
update_last_login_date(account_id)
end
end
Database Migrations ↥
Minimal Schema (Login + Logout Only)
# db/migrations/001_create_rodauth_tables.rb
require 'sequel'
Sequel.migration do
change do
# ── Accounts table ──
create_table(:accounts) do
primary_key :id
String :email, null: false, unique: true
Integer :status_id, null: false, default: 2 # 2 = verified/open
String :ph # password hash (BCrypt)
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
DateTime :updated_at
end
# ── Password hashes (separate table for security) ──
create_table(:account_password_hashes) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :password_hash, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_password_hashes, :account_id, unique: true
end
end
Common Feature Tables
# db/migrations/002_create_rodauth_feature_tables.rb
Sequel.migration do
change do
# ── Remember Me ──
create_table(:account_remember_keys) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :key, null: false
DateTime :deadline, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_remember_keys, :account_id, unique: true
# ── Reset Password ──
create_table(:account_password_reset_keys) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :key, null: false
DateTime :deadline, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_password_reset_keys, :account_id, unique: true
# ── Verify Account ──
create_table(:account_verification_keys) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :key, null: false
DateTime :deadline, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_verification_keys, :account_id, unique: true
# ── OTP (TOTP) ──
create_table(:account_otp_keys) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :key, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
DateTime :last_use
Integer :num_failures, default: 0
end
add_index :account_otp_keys, :account_id, unique: true
# ── Recovery Codes ──
create_table(:account_recovery_codes) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :code, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_recovery_codes, [:account_id, :code], unique: true
# ── SMS Codes ──
create_table(:account_sms_codes) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :code, null: false
String :phone_number, null: false
DateTime :deadline, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
# ── Account Lockout ──
create_table(:account_login_failures) do
primary_key :id
foreign_key :account_id, :accounts, null: false
Integer :number, null: false, default: 1
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_login_failures, :account_id, unique: true
create_table(:account_lockouts) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :key, null: false
DateTime :deadline, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_lockouts, :account_id, unique: true
# ── Active Sessions ──
create_table(:account_active_session_keys) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :key, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
DateTime :last_use
end
add_index :account_active_session_keys, :account_id, unique: true
# ── Single Session ──
create_table(:account_session_keys) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :key, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_session_keys, :account_id, unique: true
# ── Password Expiration ──
create_table(:account_password_change_times) do
primary_key :id
foreign_key :account_id, :accounts, null: false
DateTime :changed_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_password_change_times, :account_id, unique: true
# ── Disallow Password Reuse ──
create_table(:account_previous_password_hashes) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :password_hash, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
# ── Verify Login Change ──
create_table(:account_login_change_keys) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :key, null: false
String :login, null: false
DateTime :deadline, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_login_change_keys, :account_id, unique: true
# ── Email Auth ──
create_table(:account_email_auth_keys) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :key, null: false
DateTime :deadline, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_email_auth_keys, :account_id, unique: true
# ── Audit Logging ──
create_table(:account_authentication_audit_logs) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :action, null: false
String :ip_address
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
# ── JWT Refresh Tokens ──
create_table(:account_jwt_refresh_keys) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :key, null: false
DateTime :deadline, null: false
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
add_index :account_jwt_refresh_keys, :account_id, unique: true
# ── WebAuthn ──
create_table(:account_webauthn_keys) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :webauthn_id, null: false
String :public_key, null: false
Integer :sign_count, default: 0
DateTime :last_use
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
create_table(:account_webauthn_user_ids) do
primary_key :id
foreign_key :account_id, :accounts, null: false
String :webauthn_user_id, null: false, unique: true
DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
end
# ── Account Expiration ──
create_table(:account_activity_times) do
primary_key :id
foreign_key :account_id, :accounts, null: false
DateTime :last_activity_at
DateTime :last_login_at
DateTime :expired_at
end
add_index :account_activity_times, :account_id, unique: true
end
end
Feature-to-Table Mapping
| Feature(s) | Table(s) Created |
|---|---|
| (always) | accounts, account_password_hashes |
remember | account_remember_keys |
reset_password | account_password_reset_keys |
verify_account | account_verification_keys |
otp | account_otp_keys |
recovery_codes | account_recovery_codes |
sms_codes | account_sms_codes |
lockout | account_login_failures, account_lockouts |
active_sessions | account_active_session_keys |
single_session | account_session_keys |
password_expiration | account_password_change_times |
disallow_password_reuse | account_previous_password_hashes |
verify_login_change | account_login_change_keys |
email_auth | account_email_auth_keys |
audit_logging | account_authentication_audit_logs |
jwt (with refresh) | account_jwt_refresh_keys |
webauthn | account_webauthn_keys, account_webauthn_user_ids |
account_expiration | account_activity_times |
Running Migrations
# Run all pending migrations
ruby -e "require 'sequel'; DB = Sequel.connect(ENV['DATABASE_URL']); Sequel.extension :migration; Sequel::Migrator.run(DB, 'db/migrations')"
# Or use the Sequel CLI
sequel -m db/migrations sqlite://db/development.sqlite3
Quick Test Data (for development)
# Insert a test account (password: "password123")
require 'bcrypt'
hash = BCrypt::Password.create('password123')
DB[:accounts].insert(
email: 'test@example.com',
status_id: 2, # verified
ph: hash,
created_at: Sequel::CURRENT_TIMESTAMP
)
Minimal Working Implementation ↥
Gemfile
source 'https://rubygems.org'
gem 'roda'
gem 'rodauth'
gem 'sequel'
gem 'sqlite3' # or 'pg', 'mysql2'
gem 'bcrypt'
gem 'rackup'
Directory Structure
my_app/
├── config.ru
├── app.rb
└── views/
└── layout.str
config.ru
require './app'
run App.freeze.app
app.rb — The Complete Minimal Example
require 'roda'
require 'sequel'
require 'rodauth'
DB = Sequel.sqlite # Use your real database in production
# ── Create the accounts table ──
DB.create_table?(:accounts) do
primary_key :id
String :email, null: false, unique: true
Integer :status_id, default: 2 # 2 = verified/unlocked
String :ph # password hash (BCrypt)
end
class App < Roda
# ── Plugins ──
plugin :render, views: 'views', engine: 'str'
plugin :flash
plugin :route_csrf
plugin :sessions, secret: ENV.fetch('SESSION_SECRET') { 'change-me' * 6 }, key: 'myapp.session'
# ── Rodauth Configuration ──
plugin :rodauth do
# Database
db DB
accounts_table :accounts
password_hash_column :ph
# Enable features
enable :login, :logout, :create_account, :reset_password
# Redirects
login_redirect { '/dashboard' }
logout_redirect { '/' }
# Params
login_param :email
password_param :password
# Session keys
session_key :account_id_key, :account_id
session_key :authenticated_by_key, :authenticated_by
# Set page title for views
title_instance_variable :@page_title
end
# ── Routing ──
route do |r|
# CSRF check for non-GET requests
check_csrf! unless r.get?
# Auto-login via remember cookie
rodauth.load_memory
# This dispatches all Rodauth routes (login, logout, etc.)
r.rodauth
# Your app routes
r.root do
# Require authentication
rodauth.require_login
view 'dashboard'
end
r.get 'dashboard' do
rodauth.require_login
"Welcome, #{rodauth.account[:email]}!"
end
end
end
views/layout.str
<!DOCTYPE html>
<html>
<head>
<title><%= @page_title || 'My App' %></title>
<style>
.flash-notice { color: green; }
.flash-error { color: red; }
.rodauth label { display: block; margin-top: 10px; }
.rodauth input { display: block; padding: 5px; }
</style>
</head>
<body>
<% if flash[:notice] %>
<div class="flash-notice"><%= flash[:notice] %></div>
<% end %>
<% if flash[:error] %>
<div class="flash-error"><%= flash[:error] %></div>
<% end %>
<%= yield %>
</body>
</html>
Running the App
bundle install
bundle exec rackup -p 9292
Then visit http://localhost:9292/ — you'll be redirected to /login.
Testing with curl
# Create an account (POST /create-account)
curl -X POST http://localhost:9292/create-account \
-d "email=test@example.com&password=secret123&password-confirm=secret123" \
-c cookies.txt
# Login (POST /login)
curl -X POST http://localhost:9292/login \
-d "email=test@example.com&password=secret123" \
-c cookies.txt -b cookies.txt
# Access dashboard (GET /dashboard)
curl http://localhost:9292/dashboard \
-b cookies.txt
# Logout (POST /logout)
curl -X POST http://localhost:9292/logout \
-c cookies.txt -b cookies.txt
The Bare Minimum (No Views)
If you want to use Rodauth without any views (pure API/JSON):
plugin :rodauth, json: true, csrf: false do
db DB
enable :login, :logout, :create_account
login_redirect { '/' }
logout_redirect { '/' }
end
route do |r|
r.rodauth # handles /login, /logout, etc. as JSON
end
With json: true, all responses are JSON:
// POST /login — success
{ "status": "success", "redirect": "/dashboard" }
// POST /login — failure
{ "status": "error", "error": "invalid_login", "message": "Invalid login or password" }
Quick Reference ↥
Configuration Options Cheat Sheet
plugin :rodauth do
# ── Core ──
db DB # Sequel database connection
accounts_table :users # Table name for accounts
password_hash_column :ph # Column for BCrypt hash
status_column :status_id # Column for account status
# ── Features ──
enable :login, :logout, :create_account, :reset_password, :otp, :remember
# ── Routes ──
login_redirect { '/home' }
logout_redirect { '/' }
require_login_redirect { '/login' }
# ── Params ──
login_param :email
password_param :password
# ── Security ──
bcrypt_cost 10 # BCrypt cost (default: 12)
require_login_confirmation false # Skip confirmation field
allow_password_change_with_old_password true
# ── Two-Factor ──
two_factor_auth_type 'totp' # Default 2FA method
otp_drift 30 # TOTP time drift tolerance
max_otp_failures 5 # Lockout after N failures
# ── Views ──
title_instance_variable :@page_title # Set page title
template_opts locals: { app_name: 'My' } # Custom locals
view('login', 'Sign In') # Override page title
# ── Session ──
session_key :account_id_key, :account_id
session_key :authenticated_by_key, :authenticated_by
session_expiration_warning 3600 # Warn before session expires
# ── Hooks ──
before :login_route { audit(request) }
after :login { notify(account) }
# ── Emails ──
email_from 'no-reply@example.com'
send_email do |email|
# Your email sending logic
end
# ── Customization ──
field_attributes { |f| 'data-field="custom"' }
formatted_field_error { |f, e| "<em>#{e}</em>" }
login_additional_form_tags { '<input type="hidden" name="ref" value="web" />' }
# ── Account Status ──
# status_id: 1 = unverified, 2 = verified, 3 = locked
unverified_account_redirect { '/verify' }
locked_account_redirect { '/locked' }
# ── Already Logged In ──
already_logged_in { redirect '/' }
end
Feature Enable Cheat Sheet
| Feature | What it adds | Routes |
|---|---|---|
login | User login | GET/POST /login |
logout | User logout | GET/POST /logout |
create_account | Self-registration | GET/POST /create-account |
reset_password | Password reset via email | GET/POST /reset-password* |
change_password | Change own password | GET/POST /change-password |
change_login | Change email/login | GET/POST /change-login |
verify_account | Verify account via email | GET/POST /verify-account |
verify_login_change | Verify login change via email | GET/POST /verify-login-change |
close_account | Delete own account | GET/POST /close-account |
remember | Remember me cookies | GET/POST /remember |
otp / two_factor_base | TOTP two-factor auth | GET/POST /otp-* |
recovery_codes | Backup recovery codes | GET/POST /recovery-codes |
sms_codes | SMS two-factor codes | GET/POST *sms-codes* |
email_auth | Email-based magic links | GET/POST /email-auth |
lockout | Account lockout on failures | GET /lockout |
active_sessions | Invalidate old sessions | (none, internal) |
single_session | One session per account | (none, internal) |
session_expiration | Auto-expire inactive sessions | (none, internal) |
audit_logging | Log security events | (none, internal) |
internal_request | Programmatic API (no HTTP) | (none, internal) |
json | JSON API responses | (modifier, no routes) |
jwt | JWT token responses | (modifier, no routes) |
Architecture Summary ↥
Request → Roda route block → r.rodauth → Auth#route!
│
▼
route_hash lookup
│
▼
handle_feature_route
│
┌──────┴──────┐
▼ ▼
GET path POST path
│ │
▼ ▼
show form process action
│ │
▼ ▼
render redirect + flash
template
Key design principles:
- Plugin-based — Rodauth is a Roda plugin, not a framework
- Feature-composed — Each feature is a mixin module, loaded on demand
- Route-hash dispatch — O(1) path-to-handler lookup, no regex scanning
- Template composition — Views are built from small partials you can override individually
- Hook-based extensibility —
before/afterhooks let you customize without overriding - Convention over configuration — Sensible defaults, minimal config needed
- Database agnostic — Works with any Sequel-supported database