A password is a single point of failure. If your email gets phished, or a third-party site you reused a password on gets breached, or your ~/.ssh agent is borrowed for thirty seconds by the wrong process — the password is compromised, and the account is gone. The only structural defense against that is a second factor: something the attacker needs to have, not just something they need to know.
I'd already added passkeys to SlashFeed (that post covers the WebAuthn side), but passkeys require platform support. A user on a machine that doesn't have Touch ID or Windows Hello configured, or someone reaching in via an SSH session to the IEx console, can't use a passkey. TOTP — the six-digit code that rotates every 30 seconds — works everywhere. All you need is a phone with an authenticator app.
So both exist now. This post is about the TOTP side.
The library: NimbleTOTP
NimbleTOTP is a Dashbit library that implements RFC 6238 (TOTP) on top of RFC 4226 (HOTP). The public API is tiny:
# Generate a 20-byte random secret (the shared key)
secret = NimbleTOTP.secret()
# Build the otpauth:// URI that QR-code scanners understand
uri = NimbleTOTP.otpauth_uri("App:[email protected]", secret, issuer: "App")
# Validate a 6-digit code the user typed
NimbleTOTP.valid?(secret, "123456") # true | false
That's essentially it. The library handles the HMAC-SHA1 math, the 30-second window, and the ±1 window drift tolerance. It deliberately does not handle storage, UI, or session gating — those are your problem, and that separation of concerns makes it very easy to audit what you're trusting.
What I needed to build on top: the database schema to persist the secret, the LiveView setup flow, the login gate that redirects MFA-enabled users to a code-entry step, and the backup code system for when someone's phone is unreachable.
The data model
TOTP state lives entirely on the users row. No join table, no separate session struct — three fields:
field :totp_secret, :binary, redact: true
field :totp_enabled_at, :utc_datetime
field :totp_backup_codes, {:array, :string}, default: [], redact: true
The secret is stored as raw binary (the 20 bytes NimbleTOTP.secret/0 returns), not as a Base32 string. The Base32 representation is purely for human display — when you show the manual-entry key to a user who can't scan the QR code, you encode it to Base32 at that point. Storing the raw bytes means one less decode step at validation time, and it avoids any ambiguity about padding.
Both totp_secret and totp_backup_codes are marked redact: true. That means Ecto redacts the values from inspect/1 output and from logger messages, so they don't accidentally appear in crash reports or development logs.
totp_enabled_at is the sentinel that controls everything. It's nil when MFA is not set up, and a timestamp when it is. This gives us a clean state machine: the secret can exist temporarily during the setup flow (we need it before the user has confirmed a code), but MFA is only "on" once totp_enabled_at is set.
The migration also creates a partial index to make admin queries fast:
create index(:users, [],
where: "totp_enabled_at IS NOT NULL",
name: :users_mfa_enabled_index
)
This index covers the "how many users have MFA enabled?" admin stat without touching rows for the majority of users who don't.
The setup flow
Setup happens in four steps on the settings page. The Accounts context exposes a small set of functions that cover each step.
Step 1: Generate a fresh secret.
def generate_totp_secret do
NimbleTOTP.secret()
end
def totp_display_key(secret) when is_binary(secret) do
Base.encode32(secret, padding: false)
end
The secret is generated fresh each time the setup page loads. It lives in the LiveView assigns until the user confirms a valid code — it is never written to the database at this stage. If the user closes the tab without finishing, nothing is persisted.
Step 2: Display the QR code and manual key.
def totp_uri(%User{email: email}, secret) when is_binary(secret) do
NimbleTOTP.otpauth_uri("SlashFeed:#{email}", secret, issuer: "SlashFeed")
end
NimbleTOTP.otpauth_uri/3 returns an otpauth://totp/... URI. I pass that to a QR code component on the page. Alongside the QR code I display the Base32-encoded key via totp_display_key/1 so users on password managers or desktop authenticators that don't scan QR codes can enter it manually. The account label format "SlashFeed:#{email}" ensures the code shows up sensibly in apps like Authy or 1Password that manage multiple accounts.
Step 3 & 4: Confirm a code, then save atomically.
def enable_totp(%User{} = user, secret, code)
when is_binary(secret) and is_binary(code) do
if NimbleTOTP.valid?(secret, code) do
{plain_codes, hashed_codes} = generate_backup_codes()
now = DateTime.utc_now(:second)
changeset =
User.totp_setup_changeset(user, %{
totp_secret: secret,
totp_enabled_at: now,
totp_backup_codes: hashed_codes
})
case Repo.update(changeset) do
{:ok, updated_user} -> {:ok, updated_user, plain_codes}
{:error, changeset} -> {:error, changeset}
end
else
{:error, :invalid_code}
end
end
This is the key atomicity point. The user types a code from their authenticator app. We validate it against the in-memory secret with NimbleTOTP.valid?/2. Only if that succeeds do we write anything to the database — the secret, the totp_enabled_at timestamp, and the hashed backup codes, all in a single Repo.update/1 call.
If the code is wrong (wrong secret, wrong clock, a typo), nothing is persisted. If Repo.update/1 fails, the backup codes we just generated are discarded and never shown. The plain-text backup codes are returned from this function exactly once — displayed on a "save these now" confirmation page — and then are gone. We never store them.
The corresponding changeset:
def totp_setup_changeset(user, attrs) do
user
|> cast(attrs, [:totp_secret, :totp_enabled_at, :totp_backup_codes])
|> validate_required([:totp_secret, :totp_enabled_at])
end
Note that totp_secret, totp_enabled_at, and totp_backup_codes are not in any user-facing form cast list. They are only reachable through these specific internal changesets, never through a general update_user/2.
Backup codes
An authenticator app is great right up until someone's phone dies at 11pm before a deadline. Backup codes are the escape hatch.
def generate_backup_codes do
plain_codes =
for _ <- 1..10 do
:crypto.strong_rand_bytes(5) |> Base.encode32(padding: false)
end
hashed_codes = Enum.map(plain_codes, &Bcrypt.hash_pwd_salt/1)
{plain_codes, hashed_codes}
end
Ten codes, each derived from 5 random bytes. Five bytes gives you 40 bits of entropy, which Base32-encodes to exactly 8 characters — easy to write down and type back in without ambiguity (no 0 vs O, no 1 vs l). After bcrypt-hashing each one, the plain text list is returned and immediately shown to the user; only the hashes hit the database.
Consuming a backup code is deliberately destructive:
def use_backup_code(%User{totp_backup_codes: codes} = user, code)
when is_list(codes) and is_binary(code) do
matched_hash = Enum.find(codes, &Bcrypt.verify_pass(code, &1))
if matched_hash do
remaining = List.delete(codes, matched_hash)
user |> Ecto.Changeset.change(totp_backup_codes: remaining) |> Repo.update()
else
{:error, :invalid_code}
end
end
Enum.find/2 with Bcrypt.verify_pass/2 scans the list sequentially, which is fine given we cap at ten codes. When a match is found, List.delete/2 removes exactly the first occurrence of that hash from the list and the updated list is persisted. Use a code once, it's gone. An attacker who intercepts a code in transit can't reuse it.
The login gate
After a user passes the password check, the session controller looks at totp_enabled_at before creating a full session:
case Accounts.get_user_by_email_and_password(email, password) do
nil ->
# generic error, don't reveal whether email exists
{:error, :invalid_credentials}
%User{totp_enabled_at: nil} = user ->
# MFA not set up — create session immediately
UserAuth.log_in_user(conn, user)
%User{} = user ->
# MFA active — store user_id in session, redirect to challenge
conn
|> put_session(:mfa_pending_user_id, user.id)
|> redirect(to: ~p"/users/mfa-challenge")
end
The user id goes into the session under :mfa_pending_user_id, a key that has no privileges. The actual session token (user_token) is only written after the MFA challenge passes. Until that happens, the user can't reach any protected route.
The challenge LiveView calls Accounts.verify_totp/2:
def verify_totp(%User{totp_secret: secret, totp_enabled_at: enabled_at}, code)
when is_binary(secret) and not is_nil(enabled_at) and is_binary(code) do
if NimbleTOTP.valid?(secret, code) do
:ok
else
{:error, :invalid_code}
end
end
def verify_totp(_user, _code), do: {:error, :invalid_code}
A subtle guard clause worth explaining
That catch-all clause at the bottom — def verify_totp(_user, _code), do: {:error, :invalid_code} — is more than defensive boilerplate. Without it, calling verify_totp/2 on a user who has totp_secret set but totp_enabled_at still nil (the in-between state during setup) would raise FunctionClauseError. That exception would leak to the caller as a 500, not a clean {:error, :invalid_code}. More importantly, it would be a signal to an attacker: a FunctionClauseError at the MFA challenge endpoint tells them they've found an account mid-setup, which is potentially useful.
The catch-all swallows every variation — no secret, no enabled timestamp, wrong types, the lot — and returns the same opaque error. The caller doesn't learn anything from the failure mode.
A matching helper lives directly on the schema:
def mfa_enabled?(%__MODULE__{totp_enabled_at: nil}), do: false
def mfa_enabled?(%__MODULE__{}), do: true
def mfa_enabled?(_), do: false
mfa_enabled?/1 is used in templates to conditionally show the "disable MFA" button and in tests to assert state clearly. The third clause handles the case where the function receives something that isn't a User at all — a nil from a failed database lookup, for instance. It returns false rather than raising, which means callers don't need to guard before calling it.
Disabling MFA
Disabling requires a current password confirmation — you don't want someone who grabs an unlocked laptop for five seconds to be able to downgrade security. Once that check passes:
def disable_totp(%User{} = user) do
user |> User.totp_disable_changeset() |> Repo.update()
end
def totp_disable_changeset(user) do
change(user,
totp_secret: nil,
totp_enabled_at: nil,
totp_backup_codes: []
)
end
All three fields cleared in one atomic write. No lingering secret, no orphaned backup hashes. After disable_totp/1 the user is back to password-only login, and mfa_enabled?/1 returns false.
What this doesn't cover
This post focuses on the server-side logic. The QR code rendering uses a LiveView component that converts the otpauth:// URI to an SVG QR code client-side via a small JS hook — I didn't want to add a server-side QR library dependency for what amounts to a one-time setup screen. The MFA challenge page itself is a straightforward LiveView form with a single text input. The session plumbing — the mfa_pending_user_id key, the plug that gates the challenge route, the redirect back after success — is more router wiring than algorithmic complexity and would make this post twice as long.
The interesting parts were the parts described above: keeping the secret off disk until confirmation, the atomic enable_totp write that generates backup codes in the same transaction as enabling MFA, the catch-all guard clause, and the destructive single-use backup code consumption. NimbleTOTP handled the RFC; the rest was making sure the state transitions between the database, the session, and the user's authenticator app were correct and leak-free.