From the blog

Two-Factor Authentication in Phoenix: Add TOTP to phx.gen.auth

By Liam Killingback ·

Two-Factor Authentication in Phoenix: Add TOTP to phx.gen.auth

mix phx.gen.auth gives you a genuinely good authentication system: hashed passwords, confirmation emails, session tokens with a database-backed lifetime, and a require_authenticated_user plug that actually works. What it does not give you is a second factor.

That gap matters the moment your app holds anything a customer would be upset to lose. A password is one credential, it gets reused across sites, and it leaks in breaches that have nothing to do with you. Two-factor authentication in Phoenix closes that hole with about 200 lines of code and one dependency, and it slots into the generated auth flow rather than replacing it.

This guide adds TOTP (the six-digit codes from Google Authenticator, 1Password, Authy or Bitwarden) to a standard phx.gen.auth install. We will cover the schema, enrolment with a QR code, the half-authenticated session that makes the second step mandatory, single-use recovery codes, replay protection, and rate limiting. Working code throughout, plus the parts people usually get wrong.

Why TOTP and not SMS

TOTP is defined by RFC 6238. You and the user’s phone share a random secret. Both sides hash that secret together with the current 30 second time window, truncate the result, and get the same six digits. Nothing travels over the network at login except those six digits.

SMS looks friendlier and is worse in every way that counts. SIM swap attacks are cheap and routine, delivery is unreliable, and you are paying a per-message fee to make your security depend on a mobile carrier’s support desk. NIST has been discouraging SMS as an authenticator for years. Passkeys are the better long-term answer, but they need per-browser handling, a fallback path, and a credential store, so TOTP remains the pragmatic first step for most Phoenix apps. Ship TOTP now, add passkeys later, keep TOTP as the fallback.

The dependencies

Two small packages, no C compiler, no service to sign up for.

# mix.exs
defp deps do
  [
    {:nimble_totp, "~> 1.0"},
    {:eqrcode, "~> 0.2"},
    {:cloak_ecto, "~> 1.3"}
  ]
end

nimble_totp generates secrets and validates codes. eqrcode renders the enrolment QR as inline SVG, so there is no image upload and no third-party chart URL leaking your users’ secrets. cloak_ecto encrypts the secret at rest, which is the single most important detail in this post.

A TOTP secret is a shared secret, not a hash. Anyone who reads it can generate valid codes forever. If you store it in plaintext, a read-only SQL injection or a leaked database backup hands an attacker a permanent second factor for every user. Encrypt it.

# lib/my_app/vault.ex
defmodule MyApp.Vault do
  use Cloak.Vault, otp_app: :my_app
end

defmodule MyApp.Encrypted.Binary do
  use Cloak.Ecto.Binary, vault: MyApp.Vault
end
# config/runtime.exs
config :my_app, MyApp.Vault,
  ciphers: [
    default: {
      Cloak.Ciphers.AES.GCM,
      tag: "AES.GCM.V1",
      key: Base.decode64!(System.fetch_env!("CLOAK_KEY")),
      iv_length: 12
    }
  ]

Generate the key once with :crypto.strong_rand_bytes(32) |> Base.encode64() and put it in your secret store. Add MyApp.Vault to your supervision tree before the repo.

The schema

Two changes. TOTP state lives on the users table next to the rest of the auth data, and recovery codes get their own table because they are consumed individually.

defmodule MyApp.Repo.Migrations.AddTwoFactorToUsers do
  use Ecto.Migration

  def change do
    alter table(:users) do
      add :totp_secret, :binary
      add :totp_confirmed_at, :utc_datetime
      add :totp_last_used_at, :utc_datetime
    end

    create table(:user_recovery_codes) do
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :hashed_code, :string, null: false
      add :used_at, :utc_datetime
      timestamps(updated_at: false)
    end

    create index(:user_recovery_codes, [:user_id])
  end
end

totp_confirmed_at is the flag that matters. A secret exists from the moment the user opens the enrolment page, but 2FA is not active until they have proved they can produce a code from it. Skipping that confirmation step is how you lock people out of their own accounts with a QR code they never scanned properly.

# lib/my_app/accounts/user.ex
schema "users" do
  field :email, :string
  field :hashed_password, :string, redact: true

  field :totp_secret, MyApp.Encrypted.Binary, redact: true
  field :totp_confirmed_at, :utc_datetime
  field :totp_last_used_at, :utc_datetime

  has_many :recovery_codes, MyApp.Accounts.RecoveryCode

  timestamps()
end

def totp_enabled?(%__MODULE__{totp_confirmed_at: nil}), do: false
def totp_enabled?(%__MODULE__{}), do: true

Enrolment: secret, QR code, confirmation

Three context functions. Start the enrolment, render the URI, confirm it.

# lib/my_app/accounts.ex
defmodule MyApp.Accounts do
  import Ecto.Query
  alias MyApp.{Repo, Accounts.User, Accounts.RecoveryCode}

  def start_totp_enrolment(%User{} = user) do
    user
    |> Ecto.Changeset.change(%{
      totp_secret: NimbleTOTP.secret(),
      totp_confirmed_at: nil,
      totp_last_used_at: nil
    })
    |> Repo.update()
  end

  def totp_uri(%User{} = user) do
    NimbleTOTP.otpauth_uri("MyApp:#{user.email}", user.totp_secret, issuer: "MyApp")
  end

  def totp_qr_svg(%User{} = user) do
    user
    |> totp_uri()
    |> EQRCode.encode()
    |> EQRCode.svg(width: 240, background_color: :transparent)
  end
end

NimbleTOTP.secret/0 returns 20 random bytes, which is what RFC 4226 asks for. The otpauth:// URI carries the secret, the issuer and the account label, and every authenticator app on earth understands it. Render the SVG straight into the template and print the Base32 secret underneath for people entering it by hand:

<div class="rounded-lg border p-6">
  <%= raw(@qr_svg) %>
  <p class="mt-4 font-mono text-sm">
    <%= Base.encode32(@user.totp_secret, padding: false) %>
  </p>
</div>

Confirmation verifies one code and, only if it is valid, switches 2FA on and issues the recovery codes.

def confirm_totp(%User{} = user, code) do
  if valid_totp?(user, code) do
    Repo.transaction(fn ->
      {:ok, user} =
        user
        |> Ecto.Changeset.change(%{totp_confirmed_at: DateTime.utc_now(:second)})
        |> Repo.update()

      {user, generate_recovery_codes(user)}
    end)
  else
    {:error, :invalid_code}
  end
end

defp valid_totp?(%User{totp_secret: nil}, _code), do: false

defp valid_totp?(%User{} = user, code) when byte_size(code) == 6 do
  NimbleTOTP.valid?(user.totp_secret, code, since: user.totp_last_used_at)
end

defp valid_totp?(_user, _code), do: false

That since: option is the replay guard, and it is easy to miss. Without it a code stays valid for its whole 30 second window, so anyone who sees it over a shoulder or reads it out of a phishing proxy can reuse it. With since: set to the last successful use, NimbleTOTP refuses any code from a period that has already been spent. Record the timestamp on every success:

def use_totp(%User{} = user, code) do
  if valid_totp?(user, code) do
    user
    |> Ecto.Changeset.change(%{totp_last_used_at: DateTime.utc_now(:second)})
    |> Repo.update()
  else
    {:error, :invalid_code}
  end
end

Recovery codes

Phones get lost, wiped and upgraded. Without a recovery path your support inbox becomes the second factor, and a support agent who can turn 2FA off on request is a social engineering target. Single-use recovery codes move that problem to something the user controls.

def generate_recovery_codes(%User{} = user, count \\ 10) do
  Repo.delete_all(from c in RecoveryCode, where: c.user_id == ^user.id)

  for _ <- 1..count do
    code = 8 |> :crypto.strong_rand_bytes() |> Base.encode32(padding: false) |> String.downcase()

    Repo.insert!(%RecoveryCode{
      user_id: user.id,
      hashed_code: Bcrypt.hash_pwd_salt(code)
    })

    code
  end
end

Hash them. They are passwords with a single use, and they deserve the same treatment as hashed_password. Return the plaintext once, show it once, and tell the user plainly that it will not be shown again.

Consuming one is a lookup plus a verify. Because the codes are hashed you cannot query by value, so you check the user’s unused codes and mark the match:

def consume_recovery_code(%User{} = user, code) do
  codes = Repo.all(from c in RecoveryCode, where: c.user_id == ^user.id and is_nil(c.used_at))

  case Enum.find(codes, &Bcrypt.verify_pass(code, &1.hashed_code)) do
    nil ->
      Bcrypt.no_user_verify()
      {:error, :invalid_code}

    match ->
      match
      |> Ecto.Changeset.change(%{used_at: DateTime.utc_now(:second)})
      |> Repo.update()
  end
end

Bcrypt.no_user_verify/0 on the miss keeps the timing roughly constant, the same trick phx.gen.auth already uses in get_user_by_email_and_password/2.

The half-authenticated session

This is the part that decides whether your 2FA is real. The generated SessionController calls UserAuth.log_in_user/3 as soon as the password checks out, and that function renews the session, writes a token and returns a fully logged-in connection. If you verify the second factor after that call, an attacker with the password simply stops at the challenge page and browses on with a valid session.

So do not log the user in yet. Park the user id in the session under a different key and redirect to the challenge:

# lib/my_app_web/controllers/user_session_controller.ex
def create(conn, %{"user" => %{"email" => email, "password" => password} = params}) do
  case Accounts.get_user_by_email_and_password(email, password) do
    nil ->
      conn
      |> put_flash(:error, "Invalid email or password")
      |> render(:new, error_message: nil)

    user ->
      if Accounts.User.totp_enabled?(user) do
        conn
        |> renew_session()
        |> put_session(:pending_totp_user_id, user.id)
        |> put_session(:pending_totp_params, Map.take(params, ["remember_me"]))
        |> redirect(to: ~p"/users/two-factor")
      else
        UserAuth.log_in_user(conn, user, params)
      end
  end
end

Call renew_session/1 before writing the pending key so the session id rotates at the point the password is accepted, not later. The challenge controller is the only place that turns the pending id into a real login:

defmodule MyAppWeb.UserTwoFactorController do
  use MyAppWeb, :controller

  alias MyApp.Accounts
  alias MyAppWeb.UserAuth

  plug :require_pending_user

  def new(conn, _params), do: render(conn, :new, error_message: nil)

  def create(conn, %{"totp" => %{"code" => code}}) do
    user = conn.assigns.pending_user

    result =
      if String.length(code) > 6 do
        Accounts.consume_recovery_code(user, code)
      else
        Accounts.use_totp(user, code)
      end

    case result do
      {:ok, _} ->
        conn
        |> delete_session(:pending_totp_user_id)
        |> UserAuth.log_in_user(user, conn |> get_session(:pending_totp_params) || %{})

      {:error, :invalid_code} ->
        render(conn, :new, error_message: "That code is not valid. Try the next one.")
    end
  end

  defp require_pending_user(conn, _opts) do
    case get_session(conn, :pending_totp_user_id) do
      nil ->
        conn
        |> put_flash(:error, "Sign in first.")
        |> redirect(to: ~p"/users/log_in")
        |> halt()

      id ->
        assign(conn, :pending_user, Accounts.get_user!(id))
    end
  end
end

Route it in the same scope as the rest of the session routes, outside require_authenticated_user, because the user is deliberately not authenticated yet:

scope "/", MyAppWeb do
  pipe_through [:browser, :redirect_if_user_is_authenticated]

  get "/users/two-factor", UserTwoFactorController, :new
  post "/users/two-factor", UserTwoFactorController, :create
end

One more detail: give the pending state an expiry. A pending_totp_user_id that survives for the life of the cookie is a half-open door. Store a timestamp alongside it and reject anything older than five minutes.

Rate limiting the challenge

Six digits is a million possibilities, and a code stays valid for 30 seconds. An unthrottled endpoint is brute forceable in an afternoon. Hammer handles this in a few lines:

defp check_rate(user_id) do
  case Hammer.check_rate("totp:#{user_id}", :timer.minutes(15), 10) do
    {:allow, _count} -> :ok
    {:deny, _limit} -> {:error, :too_many_attempts}
  end
end

Ten attempts per fifteen minutes per user is generous for a human and useless for a script. Key on the user id rather than the IP, because the attacker controls the IP and not the account. Log every denial: a burst of failed second factors on one account is one of the clearest signals you will ever get that a password has leaked.

Enforcing it across a team

Once this works for individuals, the next request is always “our admins must have 2FA on.” That is an authorisation rule, not an authentication one, so keep it out of the login path and put it in an on_mount hook that redirects to the enrolment page when the policy demands a factor the user has not set up. If your plans decide who has to comply, it is the same shape as any other plan rule. Our guide to plan-based feature gating and entitlements in Phoenix covers that pattern, and Aurora Meter implements it as a library if you would rather not hand-roll the plan lookup.

Testing it

The whole flow is testable without a phone. NimbleTOTP.verification_code/1 produces the code your user’s app would show:

test "requires a valid totp before the session is created", %{conn: conn} do
  user = user_fixture() |> confirmed_totp_fixture()

  conn =
    post(conn, ~p"/users/log_in", %{
      "user" => %{"email" => user.email, "password" => valid_user_password()}
    })

  assert redirected_to(conn) == ~p"/users/two-factor"
  refute get_session(conn, :user_token)

  conn =
    post(conn, ~p"/users/two-factor", %{
      "totp" => %{"code" => NimbleTOTP.verification_code(user.totp_secret)}
    })

  assert get_session(conn, :user_token)
end

Write the negative case too, because that is the one that regresses: assert that hitting /users/two-factor with no pending id redirects to the login page, and that a replayed code fails the second time.

What this costs you

Be honest with yourself about the tradeoffs before you make 2FA mandatory.

Clock drift is real. NimbleTOTP accepts the current period only, so a phone whose clock is a minute out will fail every code. Your error copy should say “check your phone’s clock is set automatically” rather than “invalid code.”

Support load goes up. People lose phones. Recovery codes absorb most of it, but you still need a documented, identity-verified process for the user who lost both, and that process is now the weakest link in the chain. Write it down before you turn the feature on.

And TOTP does not stop real-time phishing. A proxy that relays the login page can relay the six digits too. That is the argument for passkeys, and the reason to treat TOTP as a floor rather than a ceiling.

None of that outweighs the benefit. Credential stuffing against reused passwords is the most common way small SaaS accounts get taken over, and a second factor stops it dead.

Or skip the wiring

Everything above is roughly a day of work the first time and an hour the fifth time. phx_saas ships the auth layer already assembled, including the session lifecycle this post modifies, so you can add the second factor to working code instead of building the first factor first. If you would rather have social login before 2FA, our walkthrough of Phoenix OAuth login with Google and GitHub plugs into the same UserAuth module, and the two features compose: OAuth users skip the password and still hit the TOTP challenge.

If you are building more than one app this year, the Builder Pass gets you every template for a single lifetime price, which is usually cheaper than the second week you would otherwise spend rebuilding authentication.

Add the second factor. Your future self, reading a breach disclosure about a password your users reused, will be glad you did.