Sigra's password reset flow uses a one-time, HMAC-signed token with a 60-minute TTL. The user requests a reset, clicks an email link, sets a new password, and every one of their existing sessions is invalidated. This guide covers the full lifecycle.
MyAppWeb.ResetPasswordLive— two LiveViews: one for "request a reset" and one for "enter your new password."MyApp.Accounts.deliver_user_reset_password_instructions/2— generates a reset token, stores its hash inusers_tokens, sends the email.MyApp.Accounts.get_user_by_reset_password_token/1— verifies the token (HMAC, hash lookup, TTL) and returns the user.MyApp.Accounts.reset_user_password/2— updates the password, deletes the reset token, and invalidates every session and token for that user.Sigra.Auth.request_password_reset/3— library primitive. Enumeration-safe: returns:okwhether the email exists or not.Sigra.Auth.reset_password/4— library primitive. Verifies the HMAC, looks up the token by hash, enforces the 60-minute TTL.
The generated ResetPasswordLive renders a form with one field: email. Submitting it calls:
def handle_event("send_reset", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&url(~p"/users/reset-password/#{&1}")
)
end
# Enumeration-safe: same response whether or not the email exists
{:noreply,
socket
|> put_flash(:info, "If your email is in our system, you will receive reset instructions shortly.")
|> redirect(to: ~p"/")}
end
deliver_user_reset_password_instructions/2 generates a 32-byte random token, signs its hash with HMAC using your app's secret_key_base, stores the row in users_tokens with context: "reset_password", and renders the email template.
Enumeration prevention is the critical property: render the same flash message whether the email exists or not. Attackers cannot use the reset form as an email oracle.
The email body contains a URL like:
https://myapp.com/users/reset-password/SFMyNTY.g2gDbQ...
The visible portion after /users/reset-password/ is the HMAC-signed token. When the user clicks it, ResetPasswordLive (mounted for :edit) calls:
def mount(%{"token" => token}, _session, socket) do
socket = assign(socket, token: token)
case Accounts.get_user_by_reset_password_token(token) do
%User{} = user ->
{:ok, assign(socket, user: user, form: to_form(Accounts.change_user_password(user)))}
nil ->
{:ok,
socket
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|> redirect(to: ~p"/")}
end
end
get_user_by_reset_password_token/1 verifies:
- The HMAC signature (rejects tampered tokens).
- The hash exists in
users_tokens(rejects fabricated or already-used tokens). inserted_at + 60 minutes > now(rejects expired tokens).
All three checks run in constant time where relevant.
The LiveView renders the new-password form. Submitting calls:
def handle_event("reset", %{"user" => user_params}, socket) do
case Accounts.reset_user_password(socket.assigns.user, user_params) do
{:ok, _user} ->
{:noreply,
socket
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: ~p"/users/log-in")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
Inside reset_user_password/2:
def reset_user_password(user, attrs) do
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
|> Repo.transact()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
Note: by_user_and_contexts_query(user, :all) deletes every token for that user — all sessions (including the current one on other devices), all remember-me cookies, all pending confirmation tokens, all magic links. The user must log in fresh on every device.
The reset token TTL is 60 minutes by default. To change it:
# config/config.exs
config :my_app, MyApp.Auth.Config,
reset: [token_ttl: 30 * 60] # 30 minutes, in seconds
Shorter is safer (smaller attack window) but worsens UX if the email is delayed.
Reset requests are a common enumeration vector and an email-bomb vector. Sigra honors Hammer-backed rate limiting on deliver_user_reset_password_instructions/2 when you configure it:
config :my_app, MyApp.Auth.Config,
rate_limiting: [
reset_password: [scale_ms: 60_000, limit: 3] # 3 per minute per email
]
With this config, the 4th reset request for the same email within 60 seconds returns :ok (enumeration-safe) but never enqueues the email.
When a password is reset, Sigra assumes the user forgot it (best case) or that an attacker had access (worst case). In both cases, every existing session must be invalidated:
- Any other device still logged in could be the attacker.
- Any lingering remember-me cookie must stop working.
- Any pending magic-link or confirmation token should be revoked.
The delete_all on users_tokens handles all of this in one query.
test "delivers reset email" do
user = Sigra.Testing.user_fixture()
Accounts.deliver_user_reset_password_instructions(user, &"/reset/#{&1}")
Sigra.Testing.assert_email_sent(to: user.email, subject: "Reset")
end
test "resets password and invalidates sessions" do
%{user: user, conn: _conn} = Sigra.Testing.authenticated_fixture()
{:ok, %{to_reset: token}} = Sigra.Auth.request_password_reset(Repo, user.email, user_token_schema: UserToken)
assert {:ok, updated} = Accounts.reset_user_password(user, %{"password" => "brandnewpw1234"})
Sigra.Testing.assert_sessions_invalidated(Repo, updated)
end
- Login and Logout — session token lifecycle.
- Account Lifecycle — change password without a reset token.
- Audit Logging — reset attempts are logged as
auth.password_reset_requestandauth.password_reset.success|failure. Sigra.Auth.request_password_reset/3andSigra.Auth.reset_password/4— library primitives.