Digging Deeper / Rodauth

A quick-reference guide to Rodauth's architecture, internals, customization, and minimal setup.


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:

ModuleWhat 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_csrf or :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:

CategoryWhat they areExample
auth_value_methodsConfiguration values (setters/getters)login_redirect, db
auth_methodsPublic runtime methodslogin, logged_in?
auth_private_methodsPrivate 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_id in session
  • 2FA → appends to authenticated_by array
  • 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

TemplateUsed By
loginLogin page
login-formLogin form partial
login-fieldLogin input field
password-fieldPassword input field
buttonSubmit button
logoutLogout confirmation
reset-password-requestRequest password reset
reset-passwordNew password form
rememberRemember me settings
otp-setupTOTP enrollment
otp-authTOTP code entry
otp-disableTOTP removal
two-factor-manage2FA method management
two-factor-auth2FA verification
change-passwordPassword change form
create-accountAccount creation form
verify-accountAccount verification
lockoutLocked 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 KeyPurpose
authenticated_byArray of auth methods used
two_factor_setupWhether 2FA was fully set up
two_factor_auth_failuresFailed 2FA attempt counter
otp_authWhether TOTP was verified
otp_setupWhether 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
rememberaccount_remember_keys
reset_passwordaccount_password_reset_keys
verify_accountaccount_verification_keys
otpaccount_otp_keys
recovery_codesaccount_recovery_codes
sms_codesaccount_sms_codes
lockoutaccount_login_failures, account_lockouts
active_sessionsaccount_active_session_keys
single_sessionaccount_session_keys
password_expirationaccount_password_change_times
disallow_password_reuseaccount_previous_password_hashes
verify_login_changeaccount_login_change_keys
email_authaccount_email_auth_keys
audit_loggingaccount_authentication_audit_logs
jwt (with refresh)account_jwt_refresh_keys
webauthnaccount_webauthn_keys, account_webauthn_user_ids
account_expirationaccount_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

FeatureWhat it addsRoutes
loginUser loginGET/POST /login
logoutUser logoutGET/POST /logout
create_accountSelf-registrationGET/POST /create-account
reset_passwordPassword reset via emailGET/POST /reset-password*
change_passwordChange own passwordGET/POST /change-password
change_loginChange email/loginGET/POST /change-login
verify_accountVerify account via emailGET/POST /verify-account
verify_login_changeVerify login change via emailGET/POST /verify-login-change
close_accountDelete own accountGET/POST /close-account
rememberRemember me cookiesGET/POST /remember
otp / two_factor_baseTOTP two-factor authGET/POST /otp-*
recovery_codesBackup recovery codesGET/POST /recovery-codes
sms_codesSMS two-factor codesGET/POST *sms-codes*
email_authEmail-based magic linksGET/POST /email-auth
lockoutAccount lockout on failuresGET /lockout
active_sessionsInvalidate old sessions(none, internal)
single_sessionOne session per account(none, internal)
session_expirationAuto-expire inactive sessions(none, internal)
audit_loggingLog security events(none, internal)
internal_requestProgrammatic API (no HTTP)(none, internal)
jsonJSON API responses(modifier, no routes)
jwtJWT 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:

  1. Plugin-based — Rodauth is a Roda plugin, not a framework
  2. Feature-composed — Each feature is a mixin module, loaded on demand
  3. Route-hash dispatch — O(1) path-to-handler lookup, no regex scanning
  4. Template composition — Views are built from small partials you can override individually
  5. Hook-based extensibility — before/after hooks let you customize without overriding
  6. Convention over configuration — Sensible defaults, minimal config needed
  7. Database agnostic — Works with any Sequel-supported database