902 words, 5 min read

Two-factor authentication (2FA) is a great way to improve account security. This post walks through how to add TOTP-based 2FA to a Phoenix LiveView app using the built-in phx.gen.auth authentication system.

We’ll cover:

  • Adding required fields and dependencies
  • 2FA setup flow using LiveView
  • TOTP challenge during login
  • LiveView-specific login handling

Add dependencies

Add the following libraries to mix.exs:

defp deps do
[
{:nimble_totp, "~> 1.0"},
{:qr_code, "~> 2.2"}
]
end

Then run:

mix deps.get

Extend the user schema

Add two fields to your users table:

# priv/repo/migrations/*_add_totp_to_users.exs
alter table(:users) do
add :totp_secret, :string
add :totp_confirmed_at, :utc_datetime
end

Migrate:

mix ecto.migrate

In your user schema:

schema "users" do
field :totp_secret, :string
field :totp_confirmed_at, :utc_datetime
# ...
end

Ensure your changeset/2 casts these fields:

def changeset(user, attrs) do
user
|> cast(attrs, [:email, :totp_secret, :totp_confirmed_at])
|> validate_required([:email])
end

Add 2FA setup LiveView

Create a new LiveView at /settings/two_factor that generates a secret, renders a QR code, and lets the user enter their TOTP code.

defmodule MyAppWeb.TwoFactorSetupLive do
use MyAppWeb, :live_view
alias NimbleTOTP
alias QRCode
alias MyApp.Accounts
def mount(_params, _session, socket) do
user = socket.assigns.current_user
if user.totp_secret do
{:ok, assign(socket, setup?: false)}
else
secret = Base.encode32(NimbleTOTP.secret())
uri = NimbleTOTP.otpauth_uri("MyApp:#{user.email}", Base.decode32!(secret), issuer: "MyApp")
{:ok, png} = QRCode.create(uri, :png)
b64 = Base.encode64(png)
{:ok,
assign(socket,
setup?: true,
secret: secret,
qr_code: "data:image/png;base64,#{b64}",
code: "",
error: nil
)}
end
end
def handle_event("verify", %{"code" => code}, socket) do
secret = Base.decode32!(socket.assigns.secret)
if NimbleTOTP.valid?(secret, code) do
{:ok, _} =
Accounts.update_user(socket.assigns.current_user, %{
totp_secret: socket.assigns.secret,
totp_confirmed_at: DateTime.utc_now()
})
{:noreply,
socket
|> put_flash(:info, "2FA enabled.")
|> push_redirect(to: "/settings")}
else
{:noreply, assign(socket, error: "Invalid code")}
end
end
end
<!-- templates/two_factor_setup_live.html.heex -->
<h1>Two-Factor Authentication</h1>
<%= if @setup? do %>
<p>Scan this QR code in your authenticator app:</p>
<img src={@qr_code} />
<form phx-submit="verify">
<label>Code:</label>
<input type="text" name="code" />
<button>Verify</button>
</form>
<%= if @error, do: content_tag(:p, @error, class: "text-red-500") %>
<% else %>
<p>2FA is already enabled.</p>
<% end %>

Add a TOTP challenge LiveView

When a user logs in with 2FA enabled, redirect them to a LiveView at /two_factor to enter their code.

defmodule MyAppWeb.TwoFactorChallengeLive do
use MyAppWeb, :live_view
alias MyApp.Accounts
alias NimbleTOTP
alias MyAppWeb.UserAuth
def mount(_params, %{"pending_user_id" => id}, socket) do
{:ok, assign(socket, user: Accounts.get_user!(id), code: "", error: nil)}
end
def handle_event("verify", %{"code" => code}, socket) do
user = socket.assigns.user
if NimbleTOTP.valid?(Base.decode32!(user.totp_secret), code) do
{:noreply,
socket
|> put_flash(:info, "Logged in with 2FA.")
|> UserAuth.log_in_user(user, %{})}
else
{:noreply, assign(socket, error: "Invalid code")}
end
end
end
<!-- templates/two_factor_challenge_live.html.heex -->
<h1>Enter your 2FA code</h1>
<form phx-submit="verify">
<input type="text" name="code" />
<button>Verify</button>
</form>
<%= if @error, do: content_tag(:p, @error, class: "text-red-500") %>

Update login logic

After validating the user’s password (in your LiveView or controller), check if TOTP is enabled:

if user.totp_confirmed_at do
socket
|> Phoenix.LiveView.put_session(:pending_user_id, user.id)
|> Phoenix.LiveView.redirect(to: "/two_factor")
else
UserAuth.log_in_user(socket, user, %{})
end

In controllers, you’d use put_session(conn, ...) and redirect(conn, ...) instead.


Update UserAuth helper

Make sure your UserAuth module has a LiveView-compatible log_in_user/3:

def log_in_user(socket, user, _params \\ %{}) do
token = MyApp.Accounts.generate_user_session_token(user)
socket
|> Phoenix.LiveView.put_session(:user_token, token)
|> Phoenix.LiveView.redirect(to: "/")
end

Done

You now have a working TOTP 2FA flow in Phoenix LiveView:

  • Users opt in to 2FA in their settings
  • A QR code is shown and confirmed
  • 2FA is required on next login
  • All handled with clean LiveView flows

From here, you can extend the system with:

  • Disabling 2FA
  • Backup codes
  • Remembering trusted devices

Let me know if you'd like a follow-up on any of those features.