Authentication
Add Rodauth-based authentication to your Plutonium app.
Goal
Authenticated users can sign up, log in, change passwords, and reset forgotten passwords. Pages in protected portals are gated.
Quick path — basic user auth
# 1. Install Rodauth
rails generate pu:rodauth:install
# 2. Create a user account type
rails generate pu:rodauth:account user
# 3. Run migrations
rails db:prepare
# 4. Wire auth into a portal
# (when you run `pu:pkg:portal admin --auth=user`, this happens automatically)If you generated the portal with --auth=user, the engine is already mounted with the Rodauth::Rails.authenticate(:user) constraint — open packages/admin_portal/config/routes.rb to see it. The wiring looks like:
# packages/admin_portal/config/routes.rb (generated)
Rails.application.routes.draw do
constraints Rodauth::Rails.authenticate(:user) do
mount AdminPortal::Engine, at: "/admin"
end
endIf you generated the portal as --public and need to switch it to authenticated later, re-run with --auth=user --force (or edit the constraint into the routes file by hand).
For accounts with more features, options, and admin patterns: see Reference › Auth › Accounts.
Common variations
Multi-factor auth (TOTP)
rails generate pu:rodauth:account user --otp --recovery_codesThen enable in the user-facing security section (see User profile).
Hardened admin account
For an admin role with 2FA, lockout, audit logging, and no public signup, use the dedicated pu:rodauth:admin generator (a preset of pu:rodauth:account with hardened defaults):
rails generate pu:rodauth:admin adminCreate the first admin with the rake task generated alongside the account:
EMAIL=admin@example.com rails rodauth:admin
# (run without EMAIL to prompt)The task creates the account and triggers a verification email; the admin sets their own password through that flow. No password is passed on the command line.
Multi-tenant SaaS — user + entity + membership in one shot
rails generate pu:saas:setup --user Customer --entity Organization⚠️ This is a meta-generator — it also runs pu:saas:portal, pu:profile:setup, pu:saas:welcome, and pu:invites:install. Don't re-run those manually. See Reference › Auth › Accounts › SaaS setup.
API-only (JWT)
rails generate pu:rodauth:account api_user --api_only --jwt --jwt_refreshPOST /api_users/login
{"login": "user@example.com", "password": "secret"}
# → {"access_token": "...", "refresh_token": "..."}Connecting a portal to an account type
If you create the portal with --auth=, it's wired automatically:
rails generate pu:pkg:portal customer --auth=userManually, edit the portal's controller concern:
# packages/customer_portal/app/controllers/customer_portal/concerns/controller.rb
module CustomerPortal::Concerns::Controller
extend ActiveSupport::Concern
include Plutonium::Portal::Controller
include Plutonium::Auth::Rodauth(:user)
endMultiple account types — different portals use different Rodauth instances:
# Admin portal
include Plutonium::Auth::Rodauth(:admin)
# Customer portal
include Plutonium::Auth::Rodauth(:user)See Reference › App › Portals.
Multiple portals in one browser
A person can hold a session in several portals at once — signed into the admin portal and the customer portal in the same browser, with neither evicting the other. Two settings make that work, and the generators emit both:
# app/rodauth/rodauth_plugin.rb — the shared base
configure do
enable :session_isolation
end# app/rodauth/admin_rodauth_plugin.rb — once per account type
configure do
session_key_prefix "admin_"
remember_cookie_key "_admin_remember"
endBoth are required. session_key_prefix namespaces every session key a configuration touches — the account id plus authenticated_by, login_redirect, two_factor_auth_setup and the rest. session_isolation uses that prefix to decide which entries belong to whom, and stops one configuration's login from clearing another's.
Do not also set session_key
An explicit session_key is not prefixed (convert_session_key, rodauth/features/base.rb:686 — only the default value passes through it). Setting both leaves the account id on a different name from every other key, so they stop rotating together. A session that holds an account id but no authenticated_by makes Rodauth raise on every request — logged_in_via_remember_key? calls nil.include? (remember.rb:175).
A config without a prefix at all is also not isolated: its keys are Rodauth's unprefixed defaults, indistinguishable from any other unprefixed config's, so session_isolation carries nothing for it.
Why this is needed
Rodauth resets the session on every login — including the remember feature's load_memory autologin — to defend against session fixation, and rodauth-rails implements that reset as a full reset_session.
Without session_isolation, signing into one portal wipes every other portal's session. And because RodauthApp#route calls load_memory for every configuration on every request, the evicted configuration immediately autologins from its remember cookie and evicts the new one right back. The last load_memory call in the route block wins permanently, so the other portal can never hold a session at all.
session_isolation carries only the other configurations' session entries across the reset. The session id is still rotated and application session data is still cleared, so session fixation is still defeated.
Reading a raw Rodauth session key? Go through its accessor, never the literal — with a prefix set, the literal is the wrong key:
after_login do
session[:after_welcome_redirect] = session.delete(login_redirect_session_key) # ✅
session[:after_welcome_redirect] = session.delete(:login_redirect) # ❌ wrong key
endUpgrading an app generated before this existed: add enable :session_isolation once in the base plugin and session_key_prefix in each account plugin — and delete the existing session_key "_x_session" line while you're there, for the reason above.
Every key name changes together, so old session cookies stop matching and are ignored — the safe outcome. Keeping the old session_key to "preserve" logins is what produces the half-migrated session that crashes.
In practice only unremembered sessions drop: remember_cookie_key is a cookie name and isn't touched by the prefix, so anyone holding a valid _x_remember cookie is restored by load_memory on their next request, which writes the new prefixed keys. Expect logouts for users who never ticked "Remember Me", not a full sign-out.
"Remember me" is opt-in
The login form renders a "Remember Me" checkbox whenever the config enables the remember feature, and the config only issues the 14-day cookie when it's ticked:
after_login { remember_login if param_or_nil(remember_param) == remember_remember_param_value }To go back to remembering everyone whether they asked or not, swap in the unconditional form:
after_login { remember_login }Compare the value, don't just check presence
param_or_nil(remember_param) on its own is a truthiness check, and remember_param is shared with Rodauth's /remember settings page — which submits forget and disable as well as remember. A bare presence check means remember=disable would remember you. Compare against remember_remember_param_value, as above.
Plutonium's login form also passes include_hidden: false to check_box, so an unticked box sends nothing at all rather than Rails' default "0". With the value comparison that's belt-and-braces rather than load-bearing ("0" == "remember" is already false) — but it keeps a junk param off the wire and keeps the form correct for anyone who reverts the hook to a bare truthiness check.
Logged-in users can change the setting later on Rodauth's /remember page (Remember / Forget / Disable).
Customizing the auth flow
All inside app/rodauth/<name>_rodauth_plugin.rb, in the configure do block:
Custom login redirect
login_redirect do
rails_account.admin? ? "/admin" : "/dashboard"
endAfter-create hook (e.g. create a profile)
after_create_account do
Profile.create!(account_id: account_id, name: param("name"))
endPassword requirements
password_minimum_length 12
password_meets_requirements? do |password|
super(password) && password.match?(/\d/) && password.match?(/[^a-zA-Z\d]/)
endPrevent public signup
before_create_account_route do
request.halt unless internal_request?
endFull customization surface: Reference › Auth › Accounts › Common customizations.
Email setup (production)
# config/environments/production.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: "smtp.example.com",
port: 587,
user_name: ENV["SMTP_USER"],
password: ENV["SMTP_PASSWORD"]
}Override mailer templates in app/views/rodauth/<account>_mailer/.
Accessing the current user
# Controllers / views
current_user
# Policies
userCommon issues
- "You need to set up Rodauth" — run
pu:rodauth:installfirst. - Portal redirects to login even though you're authenticated — the portal mount constraint references a different Rodauth account than the portal's controller concern uses. Match them up.
- Email confirmation never arrives in development — Plutonium sets ActionMailer to
:testby default. Checktmp/letter_opener/or your mail interceptor. In production, configure SMTP (see above). - Signing into one portal signs you out of another — or one portal can never stay signed in at all. Missing
enable :session_isolationand/orsession_key_prefix; see Multiple portals in one browser.
Related
- Reference › Auth — full auth surface
- Authorization — controlling who can do what AFTER login
- Multi-tenancy — entity scoping for SaaS apps
- User invites — invitation-based onboarding
- User profile — account-settings page
