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.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.