ADR-0058: Server-side sessions, TOTP 2FA and password reset for the admin panel and the portal
| Status | Accepted |
| Date | 2026-10-13 |
| Issue | #52 (part 2: admin panel and tenant portal) |
| Pull request | #160 |
| Related | ADR-0012, ADR-0013, ADR-0022, ADR-0059 (part 1: client tokens) |
Context and problem statement
The admin panel (/admin) and the tenant portal (/portal) authenticated with an email and a password and kept
the user id in the signed Phoenix cookie session. That had several gaps, collected in
#52:
- Sessions never ended. The cookie had no
max_ageand the server had no record of it: a stolen cookie worked untilSECRET_KEY_BASEwas rotated, and "log out" only cleared one browser's cookie. Nobody could see or revoke the sessions of an account, and a password change or deactivation left other browsers logged in (deactivation was only noticed because the plugs reload the user). - One factor. A phished or reused password was enough, also for
super_admin, which can create admins and read every tenant's data. The IP whitelist protects/adminonly, not the portal. - No recovery. A forgotten password needed an operator with database access.
- Weak policy. The only rule was eight characters; bcrypt silently ignores everything after 72 bytes.
Part 1 of #52 (token revocation, refresh rotation and scoped client tokens, ADR-0059) covers the JWT side and is a separate pull request.
Decision drivers
- Revocation must be immediate and complete: logout everywhere, password change, deactivation, reset.
- No new infrastructure (no Redis), works the same on one node and in a cluster.
- Secrets at rest (TOTP secrets, tokens) follow ADR-0012: encrypted when they must be read back, hashed when they only need to be compared.
- Nothing may reveal whether an account exists (reset form, timing kept close to the login form's).
- Small, well-understood dependencies; standard authenticator apps must work.
- Existing users and tests must keep working (no forced password changes).
Considered options
Sessions
- Keep cookie sessions, add
max_ageand a per-user "sessions valid after" timestamp - expiry and a crude "log out everywhere" without a table. - Server-side session table, cookie holds a random token (hash stored) - like
phx.gen.auth. - Store sessions in ETS / Redis - fast lookups, but per-node state or a new dependency.
Second factor
- TOTP with
nimble_totp- RFC 6238, any authenticator app, ~200 lines, no dependencies. - TOTP with
pot- Erlang library, HOTP and TOTP, more surface. - WebAuthn / passkeys - phishing resistant, but a JavaScript ceremony, an asset pipeline we do not have and
a much larger library (
wax).
Password reset tokens
- Random token, SHA-256 stored in a table, single use - revocable, auditable, invalidated by any password change.
- Signed stateless token (
Phoenix.Token) with the password hash in the payload - no table, but cannot be revoked individually and leaks timing of the hash comparison into the token format.
Pros and cons of the options
Sessions: cookie + "valid after" timestamp
- Good, because no table and no query per request.
- Bad, because single sessions cannot be listed or revoked, the idle timeout needs a cookie write on every request, and open LiveViews are not disconnected.
Sessions: server-side table (chosen)
- Good, because every session can be listed (IP, browser, last activity) and revoked one by one or all at once; absolute and idle expiry are enforced on the server; the cookie holds no user id.
- Good, because a session row gives LiveViews a socket id to disconnect (
live_socket_id). - Bad, because every request and LiveView mount reads one indexed row, and
last_seen_atis written (at most once a minute per session).
Sessions: ETS / Redis
- Good, because no database read per request.
- Bad, because ETS is per node (a cluster would need replication) and Redis is new infrastructure.
TOTP with nimble_totp (chosen)
- Good, because it is tiny, maintained by Dashbit, has no dependencies and supports reuse prevention (
since) and arbitrary times (clock-skew window). - Bad, because TOTP codes can be phished in real time (no origin binding).
TOTP with pot
- Good, because it also does HOTP and multiple windows.
- Bad, because more API surface than needed and an Erlang interface.
WebAuthn
- Good, because it is phishing resistant.
- Bad, because the admin and portal pages have no asset pipeline and the ceremony needs JavaScript and a larger dependency; it can be added later next to TOTP.
Reset tokens in a table (chosen)
- Good, because tokens are single use, expire after one hour, are deleted on any password change and are void
when the account's email changes (
sent_to), and only a hash is stored. - Bad, because one more table and a prune job.
Stateless signed reset tokens
- Good, because no table.
- Bad, because a token can only be invalidated indirectly (by changing what it signs) and cannot be single use without state.
Decision
- Server-side sessions (
user_sessions): a login inserts a row with a random 32-byte token's SHA-256, the client IP and user agent; the cookie session keeps only the token (admin_session_token/tenant_session_token) andlive_socket_id(user_sessions:<id>).ConvergerWeb.Plugs.AuthandConvergerWeb.Live.AuthHooksvalidate the row on every request and mount: present, younger than the absolute lifetime (SESSION_MAX_AGE_SECONDS, 12 h, also the cookiemax_age), used within the idle timeout (SESSION_IDLE_TIMEOUT_SECONDS, 30 min;last_seen_atbumped at most once a minute), active user. Deleting rows broadcastsdisconnectto their LiveViews. Rows are deleted on logout, "log out everywhere", per-session sign out, password change (all but the current), reset, deactivation and deletion, and by asuper_adminfor another admin.SessionPruneWorker(Oban cron, every 10 minutes) deletes expired rows and disconnects their LiveViews, which also ends idle LiveViews that make no HTTP requests. - TOTP 2FA with
nimble_totpfor admin and tenant users: a 20-byte secret, encrypted withConverger.Vault(Converger.Encrypted.Binary), shown as base32 andotpauth://URI, enabled only after a valid code. Codes of the current and previous 30 s step are accepted; the accepted step is stored intotp_last_stepwith a conditionalUPDATE ... WHERE totp_last_step < step, so a code works once even under concurrent requests. With 2FA on, the password step stores only a pending login (user id and time, five minutes) in the cookie session; the session row is created after the code. Wrong codes are counted byLoginThrottleper IP and per<type>-2fa:<user id>account. Turning 2FA off needs a code or the password; asuper_admin(admins), a tenantowner/admin(their tenant's users) orConverger.Release.reset_admin_2fa/1can reset it; all audit-logged. - Required 2FA by role:
ADMIN_REQUIRE_2FA_ROLES(defaultsuper_admin) lists admin roles that are redirected to/admin/securityuntil they enroll, likemust_change_password. It is a configuration list rather than a per-user flag so operators can extend it toadminwithout a migration;config/test.exssets it to[]and tests that cover the enforcement set it explicitly. - Password reset by email: random 32-byte URL-safe token in the link, SHA-256 in
account_tokens(user_type,user_id,context,sent_to), valid one hour, single use, deleted on any password change. The request form always answers the same; requests are limited per IP (password_reset_ip, 429) and per account (password_reset_account, silently no mail). A reset clearsmust_change_password, deletes all sessions and is audit-logged. Mail goes throughConverger.MailerfromMAILER_FROM;SMTP_*configures an SMTP relay. - One password policy (
Converger.Accounts.PasswordPolicy) for both account types whenever a password is set: at leastPASSWORD_MIN_LENGTH(12) characters, at most 72 bytes, not containing the email. Existing hashes are not re-checked.
Consequences
Positive
- Sessions can be seen and revoked, and they end on their own; a stolen cookie is worth at most 12 hours, or 30 minutes when unused.
super_adminaccounts need a second factor by default.- Users recover from a forgotten password without an operator; operators recover a lost authenticator with one release command.
- Security events (2FA changes, resets, password changes, session revocation) appear in the audit log.
Negative and trade-offs
- One indexed read per admin/portal request and LiveView mount, plus at most one write per session per minute.
- The upgrade logs everyone out once (old cookies carry a user id), and existing
super_adminaccounts must enroll at their next request. - TOTP is phishable in real time; WebAuthn would not be.
- An idle LiveView that makes no requests stays connected until the next prune run (up to 10 minutes after the idle timeout).
- The reset request does a little more work when the account exists (token insert and mail send), a timing difference smaller than the network jitter but not zero.
- Production needs an SMTP relay (
SMTP_HOST) for reset emails; without it the links are never delivered. - No recovery codes: a user who loses the authenticator needs another admin, a tenant owner or the release command.
Follow-ups
- Recovery codes and WebAuthn as a second second-factor.
- An admin view of every user's sessions (today: own sessions on
/admin/security, and "Sign out" per admin).
Implementation
- Migration
20261013160000_create_user_sessions_account_tokens_and_totp.exs:user_sessions,account_tokens,totp_secret/totp_enabled_at/totp_last_steponadmin_usersandtenant_users. Converger.Accounts.Sessions,Converger.Accounts.TOTP,Converger.Accounts.PasswordReset,Converger.Accounts.PasswordPolicy,Converger.Accounts.UserNotifier,Converger.Workers.SessionPruneWorker,Converger.Release.reset_admin_2fa/1;Converger.Accounts.change_own_password/4andrevoke_sessions/2.- Web:
ConvergerWeb.UserAuth(login, logout, pending 2FA),ConvergerWeb.Plugs.Auth,ConvergerWeb.Live.AuthHooks,ConvergerWeb.TwoFactorLoginController(/admin/login/totp,/portal/login/totp),ConvergerWeb.SecurityController(/admin/security,/portal/security),ConvergerWeb.PasswordResetController(/admin/password/reset,/portal/password/reset); the session plug inConvergerWeb.Endpointreadsmax_ageat runtime. Admin users and portal users LiveViews reset 2FA. - Config:
:sessions,:password_min_length,:require_2fa_roles,:mailer_from(config/config.exs), env variables inconfig/runtime.exs; rate limit bucketspassword_reset_ip,password_reset_account. - Tests:
test/converger/accounts/{sessions,totp,password_reset,password_policy}_test.exs,test/converger_web/controllers/account_security_test.exs(expiry, log out everywhere with the LiveView disconnect broadcast, 2FA login, reuse, throttle, required 2FA forsuper_admin, resets),test/converger_web/controllers/password_reset_controller_test.exs.ConnCase.log_in_admin/2andlog_in_tenant_user/2create real session rows.