The letter A styled as Alchemists logo. lchemists
Published February 1, 2026 Updated May 13, 2026
Cover
Hanami with Rodauth

Security is an important part of web application engineering and a good solution for implementing authentication within your application, both in terms of UI and API, is Rodauth. That said, Rodauth does come with a learning curve. The following will walk you through setting up Rodauth in your Hanami application because, unless you are a security expert, you won’t want to do this yourself.

Setup

To get started, add the following gems to your application:

bundle add bcrypt jwt rodauth

Here’s what each provides:

  • Bcrypt: Necessary for authentication, passwords, and encryption in general.

  • JWT: Necessary for API authentication.

  • Rodauth: Necessary to implement authentication for both UI and API purposes.

We’ll use these gems to leverage the Rodauth features.

Features

While there are plenty of features you can enable, we’ll focus on the following in the order listed as a minimum to get you started:

Only the Base feature is automatic and provided for you by default. The rest you must manually add. You can definitely add or subtract from this list, based on your needs, but this is the feature set we’ll be working through together.

Database

You’ll want to spend the most time thinking through which Rodauth features you want to support and configure. Once you’ve mapped out your desired feature set, it’s helpful to add # Feature: <feature> code comments so you have a direct reference to the feature associated with each table you are creating. This same code comment can be reused when adding the middleware necessary for enabling and configuring this feature set (more on this soon). Here’s the associated database migrations based on the previously discussed feature set:

User Migration

Necessary to authenticate and manage users within the system. This supports multiple Rodauth features. A code comment denotes each enabled feature.

ROM::SQL.migration do
  up do
    extension :date_arithmetic

    deadline = lambda do |days|
      {null: false, default: Sequel.date_add(Sequel::CURRENT_TIMESTAMP, days:)}
    end

    # Feature (automatic): base
    create_table :user_status do
      primary_key :id

      column :name, String, unique: true, null: false
    end

    from(:user_status).import(%i[id name], [[1, "Unverified"], [2, "Verified"], [3, "Closed"]])

    create_table :user do
      primary_key :id, type: :Bignum
      foreign_key :status_id, :user_status, null: false, default: 1

      column :name, String
      citext :email, null: false
      column :settings, :jsonb, null: false, default: "{}"
      column :created_at, :timestamp, null: false, default: Sequel::CURRENT_TIMESTAMP
      column :updated_at, :timestamp, null: false, default: Sequel::CURRENT_TIMESTAMP

      constraint :valid_email, email: /^[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+$/

      index :email, unique: true, where: {status_id: [1, 2]}
      index :settings, type: :gin
    end

    # Feature: active_sessions
    create_table :user_active_session_key do
      foreign_key :user_id, :user, type: :Bignum

      column :session_id, String
      column :created_at, Time, null: false, default: Sequel::CURRENT_TIMESTAMP
      column :last_use, Time, null: false, default: Sequel::CURRENT_TIMESTAMP

      primary_key %i[user_id session_id]
    end

    # Feature: audit_logging
    create_table :user_authentication_audit_log do
      primary_key :id, type: :Bignum
      foreign_key :user_id, :user, null: false, type: :Bignum

      column :at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP
      column :message, String, null: false
      column :metadata, :jsonb

      index %i[user_id at], name: :audit_user_at_idx
      index :at, name: :audit_at_idx
    end

    # Feature: jwt_refresh
    create_table :user_jwt_refresh_key do
      primary_key :id, type: :Bignum
      foreign_key :user_id, :user, null: false, type: :Bignum

      column :key, String, null: false
      column :deadline, DateTime, deadline.call(1)

      index :user_id, name: :user_jwt_refresh_key_user_id_idx
    end

    # Feature: recovery_codes
    create_table :user_recovery_code do
      foreign_key :id, :user, type: :Bignum

      column :code, String

      primary_key %i[id code]
    end

    # Feature: remember
    create_table :user_remember_key do
      foreign_key :id, :user, primary_key: true, type: :Bignum

      column :key, String, null: false
      column :deadline, DateTime, deadline.call(14)
    end

    run %(GRANT REFERENCES ON "user" TO CURRENT_USER)
    run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_status TO CURRENT_USER"
    run %(GRANT SELECT, INSERT, UPDATE, DELETE ON "user" TO CURRENT_USER)
    run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_active_session_key TO CURRENT_USER"
    run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_authentication_audit_log TO CURRENT_USER"
    run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_jwt_refresh_key TO CURRENT_USER"
    run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_recovery_code TO CURRENT_USER"
    run "GRANT SELECT, INSERT, UPDATE, DELETE ON user_remember_key TO CURRENT_USER"
  end

  down do
    drop_table :user_remember_key,
               :user_recovery_code,
               :user_jwt_refresh_key,
               :user_authentication_audit_log,
               :user_active_session_key,
               :user_status,
               :user
  end
end
User Hash Migration

Necessary to capture user password hashes. This is done as a separate table with different permissions in order to improve security as documented here.

require "rodauth/migrations"

ROM::SQL.migration do
  settings = {table_name: :user_password_hash}

  up do
    # Feature (automatic): base
    create_table :user_password_hash do
      foreign_key :id, :user, primary_key: true, type: :Bignum
      column :password_hash, String, null: false
    end

    Rodauth.create_database_authentication_functions self, **settings

    run "REVOKE ALL ON user_password_hash FROM public"
    run "REVOKE ALL ON FUNCTION rodauth_get_salt(int8) FROM public"
    run "REVOKE ALL ON FUNCTION rodauth_valid_password_hash(int8, text) FROM public"
    run "GRANT INSERT, UPDATE, DELETE ON user_password_hash TO CURRENT_USER"
    run "GRANT SELECT(id) ON user_password_hash TO CURRENT_USER"
    run "GRANT EXECUTE ON FUNCTION rodauth_get_salt(int8) TO CURRENT_USER"
    run "GRANT EXECUTE ON FUNCTION rodauth_valid_password_hash(int8, text) TO CURRENT_USER"
  end

  down { Rodauth.drop_database_authentication_functions self, **settings }
end

One of the most important aspects of the above code is that all tables are prefixed as user tables. This is because, by default, Rodauth assumes account prefixes. The reason for making these user based tables instead of account tables is because this allows you to scale to more sophisticated user/account management in terms of level of access. For example, this opens up the following possibilities:

  • Level 1 (users): Basic user login/logout and resource access.

  • Level 2 (accounts): The unit of ownership in terms of system resources for which a user can belong to an account for access.

  • Level 3 (memberships): The many to many relationship in which a user can belong to multiple accounts and an account can have multiple users.

  • Level 4 (roles): The roles (and permissions) associated with the account.

  • Level 5 (organizations): The ultimate super account (enterprise level) in which you group multiple accounts under a single organization.

For the purposes of this discussion, we’ll only focus on Level 1.

Slice

Due to Rodauth needing middleware; feature configuration; and multiple templates, it’s a good idea to encapsulate all of this functionality within an authentication slice. Here’s what this looks like structurally:

slices/authentication
├── assets
│   ├── css
│   │   └── portal.css
│   └── js
│       └── app.js
├── db
│   ├── relation.rb
│   ├── repository.rb
│   └── struct.rb
├── templates
│   ├── layouts
│   │   └── app.html.erb
│   ├── login.html.erb
│   ├── login_update.html.erb
│   ├── logout.html.erb
│   ├── password_update.html.erb
│   ├── register.html.erb
│   └── remember.html.erb
├── views
│   └── context.rb
├── feature.rb
├── middleware.rb
└── view.rb

Some of the above is boilerplate when generating a slice. That said, the following sections will detail the key files and ignore the boilerplate code.

Feature

Rodauth supports the ability to define custom features. In this case, we need a custom feature specific for bridging the gap between Hanami and Rodauth when we wire up our middleware (more on this shortly). In other words, a translation layer. In our case, we are swapping out Rodauth’s view rendering with Hanami’s view rendering but only if one of our Hanami templates exist. Otherwise, we fall back to Rodauth. Here’s what this looks like:

# slices/authentication/feature.rb

# auto_register: false

require "rodauth"

Rodauth::Feature.define :hanami do
  auth_value_method :hanami_view, nil

  def view(name, *)
    layout_path = view_base.class.layout_path view_base.config.layout
    scope = view_rendering.scope rodauth: self

    view_rendering.template(layout_path, scope) { render name }
  end

  def render name
    return super unless view_template? name

    view_rendering.template name, view_rendering.scope(rodauth: self)
  end

  private

  def view_template? name
    view_rendering.renderer.__send__ :lookup, name, view_base.config.default_format
  end

  def view_rendering
    @view_rendering ||= view_base.rendering format: view_base.config.default_format,
                                            context: view_context
  end

  def view_context
    @view_context ||= begin
                        action_request = Hanami::Action::Request.new(
                          env: request.env,
                          params: request.params,
                          session_enabled: true
                        )

                        view_base.config.default_context.class.new request: action_request
                      end
  end

  def view_base
    @view_base ||= hanami_view.call
  end
end

Middleware

The middleware layer allows you to inherit Rodauth functionality and configure behavior as Rack middleware for which you can wire into your application routes. Remember when we added the hanami feature above? Well, this is where you enable that feature along with all of the features provided by Rodauth. Here’s the implementation:

Implementation
# slices/authentication/middleware.rb

# auto_register: false

require "refinements/string"
require "roda"
require "rodauth"

require_relative "feature"

module Authentication
  # Specialized Roda middleware for authentication.
  class Middleware < Roda
    using Refinements::String

    plugin :middleware

    plugin :rodauth, json: true do
      enable :active_sessions,
             :audit_logging,
             :change_login,
             :change_password,
             :create_account,
             :disallow_common_passwords,
             :hanami,
             :jwt_refresh,
             :login,
             :logout,
             :remember,
             :recovery_codes

      db Authentication::Slice["db.gateway"].connection

      # Feature (automatic): base
      accounts_table :user
      after_login { remember_login }
      already_logged_in { redirect "/" }
      flash_error_key :alert
      hmac_secret Hanami.app[:settings].app_secret
      login_label "Email"
      password_hash_table :user_password_hash
      require_login_error_flash "Please log in to continue."
      template_opts layout: nil
      unverified_account_message "Unverified user, please verify before logging in."

      # Feature (automatic): login_password_requirements_base
      require_password_confirmation? false

      # Feature: active_sessions
      active_sessions_account_id_column :user_id
      active_sessions_table :user_active_session_key

      # Feature: audit_logging
      audit_logging_table :user_authentication_audit_log
      audit_logging_account_id_column :user_id

      # Feature: change_login
      change_login_route "me/login"
      change_login_view { view "login_update", nil }

      # Feature: change_password
      change_password_route "me/password"
      change_password_view { view "password_update", nil }
      change_password_button "Save"

      # Feature: create_account
      create_account_button "Create"
      create_account_link_text "Register."
      create_account_route "register"
      create_account_view { view "register", nil }
      change_login_button "Save"

      after_create_account do
        user_id = account[:id]
        user_name = param "name"
        account_id = db[:account].insert label: user_name, name: user_name.snakecase

        db[:user].where(id: user_id).update name: user_name
        db[:membership].insert user_id: user_id, account_id:
      end

      # Feature (custom): hanami
      hanami_view(proc { View.new })

      # Feature: jwt
      jwt_secret Hanami.app[:settings].app_secret
      jwt_refresh_route "api/jwt"

      # Feature: jwt_refresh
      jwt_access_token_not_before_period Hanami.app[:settings].api_access_token_period
      jwt_refresh_token_account_id_column :user_id
      jwt_refresh_token_table :user_jwt_refresh_key

      # Feature: login
      login_error_flash "There was an error signing in."
      login_form_footer_links_heading { nil }
      login_notice_flash "You have been logged in."
      login_return_to_requested_location? true
      multi_phase_login_view { view "login_multi_phase", nil }

      # Feature: logout
      logout_notice_flash "You have been logged out."
      logout_redirect "/"

      # Feature: remember
      remember_button "Save"
      remember_table :user_remember_key
      remember_route "me/remember"

      # Feature: recovery_codes
      recovery_codes_table :user_recovery_code
    end

    route do |request|
      env["rodauth"] = rodauth
      request.rodauth
    end
  end
end

The above might seem daunting, at first, but is mostly configuration logic. You can read it from top to bottom as follows:

  • Define the middleware plugin because we definitely want Rodauth to behave like middleware so we can wire into our routes.

  • Define the rodauth plugin and then enable all of the features you want (this includes our custom hanami feature).

  • Configure each feature. Once again, there is a code comment that helps you jump to the associated Rodauth documentation of that feature.

Definitely spend time reading through Rodauth’s feature documentation to customize further as you can adjust for your needs.

View Context

In order to not repeat yourself and leverage common styles from your main application, you’ll want to include your application’s main assets. This can be done with this view:

# authentication/views/context.rb

# auto_register: false

module Authentication
  module Views
    # The slice view context.
    class Context < Hanami::View::Context
      include Deps[main_assets: "main.assets"]
    end
  end
end

The above will allow you to use your application’s assets (mostly styles) within all templates associated with this slice.

Routes

Necessary to ensure all routes are properly authenticated by using the new Rodauth middleware at the top of our routes.

module Demos
  # The application routes.
  class Routes < Hanami::Routes
    slice(:authentication, at: "/") { use Authentication::Middleware }
  end
end

Layouts/Templates

While you can get by with Rodauth’s default layouts and templates, you’ll want to customize further. The following documents the layouts and templates used to customize the UI. These are listed by pure file structure only since providing source code for each would be slightly overkill. That said, there is a process for customization which looks like this:

  • Open the Rodauth gem in your default text editor. If using Gemsmith, this is as simple as running: gemsmith --edit rodauth.

  • Once in your editor, open the templates folder where you’ll see multiple *.str files. These, roughly, map to each feature you enabled and configured earlier. You can then take the source code and convert to ERB syntax that is compatible with Hanami Views.

  • Repeat this process for each file you need to customize further.

Here’s what the file structure looks like after using the above process:

# Layouts
app/templates/layouts/authentication.html.erb

# Templates
slices/authentication/templates/layouts/app.html.erb
slices/authentication/templates/login.html.erb
slices/authentication/templates/login_update.html.erb
slices/authentication/templates/logout.html.erb
slices/authentication/templates/password_update.html.erb
slices/authentication/templates/register.html.erb
slices/authentication/templates/remember.html.erb

In case it helps, here’s an example of converting the Rodauth logout.str template to a Hanami logout.html.erb template:

From Rodauth (logout.str)

<form method="post" class="rodauth" role="form" id="logout-form">
  #{rodauth.logout_additional_form_tags}
  #{rodauth.csrf_tag}
  #{rodauth.button(rodauth.logout_button, :class=>'btn btn-warning')}
</form>

To Hanami (logout.html.erb)

<form method="post" class="rodauth" role="form" id="logout-form">
  <%== rodauth.logout_additional_form_tags %>
  <%== rodauth.csrf_tag %>
  <%== rodauth.button rodauth.logout_button, class: "btn btn-warning" %>
</form>

Actions

With the Rodauth Authentication slice in place, you’ll want to update your root application Action so all subclasses can inherit authentication behavior. This can be done by defining a protected authorize method with a subsequent private handle_rodauth_redirect for wiring in flash and redirect support. Example:

# auto_register: false

require "hanami/action"

module Demo
  # The application base action.
  class Action < Hanami::Action
    before :authorize

    protected

    def authorize request, response
      rodauth = request.env["rodauth"]

      return unless rodauth

      handle_rodauth_redirect(rodauth, response) { rodauth.require_account }

      response[:current_user_id] = rodauth.account_id
    end

    private

    def handle_rodauth_redirect rodauth, response
      halted = catch(:halt) { yield }

      return unless halted

      code, headers, body = *halted

      rodauth.flash.next.each { |key, value| response.flash[key] = value }
      response.redirect headers["Location"], code

      throw :halt, [code, body]
    end
  end
end

The above works for all UI and API requests. In rare situations where you need to disable the authorize before callback, you can override authorize as follows in your subclass:

# frozen_string_literal: true

module Demo
  module Actions
    module API
      module Log
        # The create action.
        class Create < Action
          # Truncated for brevity...

          protected

          def authorize(*) = nil
        end
      end
    end
  end
end

The above will completely disable the authentication check for you API log request (POST) with minimal effort.

RSpec

Now that everything is wired up, you’ll want to add a couple RSpec shared contexts to aid in writing your feature and request specs. First, here’s the with login shared context you can use for all feature (UI) specs:

# demo/spec/support/shared_contexts/login.rb

RSpec.shared_context "with login" do
  let(:user) { Factory[:user, :verified] }

  before do
    Factory[:user_password_hash, id: user.id]

    visit "/login"
    fill_in "login", with: user.email
    click_button "Login"

    fill_in "Password", with: "password"
    click_button "Login"
  end
end

Second, here’s the with JWT shared context you can use for all request (API) specs:

# demo/spec/support/shared_contexts/jwt.rb

RSpec.shared_context "with JWT" do
  let(:user) { Factory[:user, :verified] }

  let :access_token do
    post "/login",
         {login: user.email, password: "password"}.to_json,
         {"CONTENT_TYPE" => "application/json"}

    JSON[last_response.body, symbolize_names: true].fetch :access_token
  end

  before { Factory[:user_password_hash, id: user.id] }
end

Feel free to customize a needed but the above will allow you to quickly authenticate, for testing purposes, when writing feature and request specs.

Tools

Due to Rodauth having a rich feature set, it quickly becomes tiresome jumping around the docomentation so, for Alfred fans, you might want to leverage the Pennyworth gem which provides a Rodauth Workflow for quick access to the documentation. Even better you can fuzzy type to quickly jump to any of the supported features.

Conclusion

As mentioned earlier, there is definitely a learning curve when folding Rodauth into your web application but is much faster/better than implementing this yourself. Plus, as you’ve seen, there’s plenty of opportunity to customize for your specific needs.

The core integration happens via the Rodauth feature and corresponding middleware. The rest is getting up to speed with all Rodauth’s feature then leveraging the power of Hanami slices, routes, views, layouts, and templates to customize as desired.

May your application be secure!