Send OTP Emails in Phoenix / Elixir (2026) with a Plain HTTP API
Build signup/login OTP in Phoenix: :crypto.strong_rand_bytes, HMAC-hashed codes, LiveView or controller, Req/Finch to POST /emails, and Idempotency-Key—Rovela delivers only.
Why Phoenix + OTP codes (not a magic-link SDK)
Phoenix already gives signup and login a clean shape: a context that owns persistence, a controller or LiveView that never holds secrets in the browser, and runtime config for API keys. You do not need a Rovela Elixir SDK, an “OTP product” endpoint, or a Plug that pretends the email API is an identity provider.
What you need is the recipe in Email verification codes: your app mints a six-digit code with :crypto.strong_rand_bytes (or :rand.uniform only if you accept weaker entropy—prefer the OS CSPRNG), stores an HMAC of it (never the digits), sets expiry and attempt limits, then POST /emails with both html and text. Rovela queues, DKIM-signs, retries, and records events. It does not generate codes, check them, or create sessions.
Rovela does not provide an OTP feature. Your Phoenix context owns cryptography, the database row, resend throttling, redeem logic, and the session / token. Rovela only delivers the mail.
This article is Phoenix-focused (LiveView form → server event, or a classic controller + JSON API): a thin Rovela client with Req or Finch, and an Idempotency-Key so a timeout retry does not enqueue two disagreeing codes. It is not a magic-link walkthrough—for those see Next.js, NestJS, and FastAPI. For product choice between digits and URLs, see Email verification: code vs link. The language-agnostic OTP guide is Sending OTP codes by email. For generic Elixir HTTP sending (HTTPoison / Finch batches, webhooks), see Send Email with Elixir—this post does not rehash transport-only patterns.
What you own vs what Rovela sends
Your Phoenix app owns:
- Six-digit mint via
:crypto.strong_rand_bytes/1→ integer in0..999_999, zero-padded (never:rand.uniformfor auth if you can avoid it; never roll your own PRNG) - HMAC-SHA256 of
email <> ":" <> codewith a server pepper (CODE_PEPPERin runtime config / env) - Expiry (docs example: ~10 minutes), attempt counter, and “one send per minute” resend gate
- Upsert by normalized email so a new send invalidates the previous code
- Constant-time compare on redeem (
Plug.Crypto.secure_compare/2); delete the row on success - Identical public responses whether or not you want to reveal account existence (product choice)
- Rate limits on LiveView events / controller actions for send and verify
Rovela owns:
POST https://api.rovela.dev/emailswithAuthorization: Bearer+ asending_accesskey (ROVELA_API_KEYin runtime config—never commit it, never put it inassets/, LiveView client hooks, orwindow)- Queueing, signing, retries, bounce/complaint handling
A 200 from the send endpoint means accepted and queued, not “in the inbox.”
Runtime config: keep sending_access off the wire to the browser
# config/runtime.exs
import Config
if config_env() == :prod do
config :acme, Acme.Mailer,
rovela_api_key: System.fetch_env!("ROVELA_API_KEY"),
code_pepper: System.fetch_env!("CODE_PEPPER"),
otp_from: System.get_env("OTP_FROM") || "Acme <security@mail.acme.com>",
otp_ttl_minutes: String.to_integer(System.get_env("OTP_TTL_MINUTES") || "10"),
resend_cooldown_sec: String.to_integer(System.get_env("OTP_RESEND_COOLDOWN_SEC") || "60"),
max_attempts: String.to_integer(System.get_env("OTP_MAX_ATTEMPTS") || "5")
end
LiveView assigns and phx- hooks must never receive ROVELA_API_KEY. Only the BEAM process that calls Rovela should read it. A sending_access key is enough—and preferred—for production OTP senders (Authentication, Quick Start).
Thin Rovela client with Req (+ Idempotency-Key)
# lib/acme/rovela.ex
defmodule Acme.Rovela do
@moduledoc false
@endpoint "https://api.rovela.dev/emails"
def send_email!(%{to: to, subject: subject, html: html, text: text, idempotency_key: key}) do
api_key = Application.fetch_env!(:acme, Acme.Mailer)[:rovela_api_key]
from = Application.fetch_env!(:acme, Acme.Mailer)[:otp_from]
case Req.post(@endpoint,
json: %{from: from, to: to, subject: subject, html: html, text: text},
headers: [
{"authorization", "Bearer #{api_key}"},
{"idempotency-key", key}
]
) do
{:ok, %{status: status, body: body}} when status in 200..299 ->
body
{:ok, %{status: status, body: body}} ->
raise "Rovela send failed: #{status} #{inspect(body)}"
{:error, reason} ->
raise "Rovela send transport error: #{inspect(reason)}"
end
end
end
Finch works the same way: build a Finch.build(:post, …) with the same headers and JSON body. The important header is Idempotency-Key (≤ 256 characters; remembered 24 hours—API: emails).
Hash at rest: strong_rand_bytes + HMAC, not bare SHA-256
Six digits are only a million possibilities. A leaked table of bare SHA-256 hashes is reversed in well under a second. Follow the docs: HMAC with a pepper that never leaves the server.
# lib/acme/accounts/otp_crypto.ex
defmodule Acme.Accounts.OtpCrypto do
@moduledoc false
def normalize_email(raw) when is_binary(raw) do
raw |> String.trim() |> String.downcase()
end
def mint_code do
# OS CSPRNG — do not use Enum.random/1 for secrets
<<n::unsigned-integer-32>> = :crypto.strong_rand_bytes(4)
rem(n, 1_000_000) |> Integer.to_string() |> String.pad_leading(6, "0")
end
def hash_code(email, code) do
pepper = Application.fetch_env!(:acme, Acme.Mailer)[:code_pepper]
:crypto.mac(:hmac, :sha256, pepper, email <> ":" <> code)
|> Base.encode16(case: :lower)
end
def safe_equal_hex(a, b) when is_binary(a) and is_binary(b) do
Plug.Crypto.secure_compare(a, b)
end
end
Suggested row shape (one active code per email), matching the verification-code example:
CREATE TABLE verification_codes (
email TEXT PRIMARY KEY,
code_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
attempts INT NOT NULL DEFAULT 0,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Map it with Ecto (Acme.Accounts.VerificationCode) the usual way: unique email, code_hash, expires_at, attempts, sent_at.
Context: send + verify
# lib/acme/accounts/otp.ex
defmodule Acme.Accounts.Otp do
import Ecto.Query
alias Acme.Repo
alias Acme.Accounts.{OtpCrypto, VerificationCode}
alias Acme.Rovela
@safe_send_msg "If that address can receive mail, a code is on its way."
def send_otp(raw_email) do
email = OtpCrypto.normalize_email(raw_email)
cfg = Application.fetch_env!(:acme, Acme.Mailer)
cooldown = cfg[:resend_cooldown_sec]
ttl = cfg[:otp_ttl_minutes]
recent? =
from(v in VerificationCode,
where: v.email == ^email and v.sent_at > ago(^cooldown, "second")
)
|> Repo.exists?()
if recent? do
{:error, :too_soon}
else
code = OtpCrypto.mint_code()
code_hash = OtpCrypto.hash_code(email, code)
expires_at = DateTime.utc_now() |> DateTime.add(ttl * 60, :second)
%VerificationCode{}
|> VerificationCode.changeset(%{
email: email,
code_hash: code_hash,
expires_at: expires_at,
attempts: 0,
sent_at: DateTime.utc_now()
})
|> Repo.insert(
on_conflict: {:replace, [:code_hash, :expires_at, :attempts, :sent_at]},
conflict_target: :email
)
# Stable per logical send: hash prefix. New code ⇒ new key.
idempotency_key = "otp-" <> String.slice(code_hash, 0, 32)
Rovela.send_email!(%{
to: email,
subject: "Your Acme code: #{code}",
html: """
<p>Your verification code is</p>
<p style="font-size:28px;font-weight:600;letter-spacing:4px">#{code}</p>
<p>It expires in #{ttl} minutes. If you did not ask for it, ignore this email.</p>
""",
text:
"Your verification code is #{code}. It expires in #{ttl} minutes. If you did not ask for it, ignore this email.",
idempotency_key: idempotency_key
})
{:ok, @safe_send_msg}
end
end
def verify_otp(raw_email, raw_code) do
email = OtpCrypto.normalize_email(raw_email)
code = String.trim(to_string(raw_code))
max_attempts = Application.fetch_env!(:acme, Acme.Mailer)[:max_attempts]
with true <- Regex.match?(~r/^\d{6}$/, code),
%VerificationCode{} = row <- Repo.get(VerificationCode, email) do
cond do
DateTime.compare(row.expires_at, DateTime.utc_now()) == :lt ->
Repo.delete(row)
{:error, :expired}
row.attempts >= max_attempts ->
{:error, :locked}
true ->
candidate = OtpCrypto.hash_code(email, code)
if OtpCrypto.safe_equal_hex(candidate, row.code_hash) do
Repo.delete(row)
# issue session / token here
:ok
else
row
|> Ecto.Changeset.change(attempts: row.attempts + 1)
|> Repo.update!()
{:error, :invalid}
end
end
else
_ -> {:error, :invalid}
end
end
end
Putting the code in the subject helps lock-screen previews—fine for many products, skip it on highly shared screens (verification-code docs). Always send both html and text with the digits in the text part (POST /emails).
For stricter attempt accounting under concurrency, prefer a single UPDATE … SET attempts = attempts + 1 WHERE … RETURNING code_hash (as in the docs) instead of read-then-write.
LiveView form (server events only) or controller
LiveView keeps the key off the client: the browser posts email/code to the LiveView process; the process calls the context.
# lib/acme_web/live/otp_live.ex
defmodule AcmeWeb.OtpLive do
use AcmeWeb, :live_view
alias Acme.Accounts.Otp
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, email: "", code: "", flash_msg: nil)}
end
@impl true
def handle_event("send", %{"email" => email}, socket) do
# Add per-IP / per-user throttle (Hammer, PlugAttack, or your own ETS)
case Otp.send_otp(email) do
{:ok, msg} -> {:noreply, assign(socket, email: email, flash_msg: msg)}
{:error, :too_soon} -> {:noreply, assign(socket, flash_msg: "Please wait before resending.")}
end
end
def handle_event("verify", %{"email" => email, "code" => code}, socket) do
case Otp.verify_otp(email, code) do
:ok ->
# put session / redirect
{:noreply, push_navigate(socket, to: ~p"/app")}
{:error, reason} ->
{:noreply, assign(socket, flash_msg: "Could not verify (#{reason}).")}
end
end
end
Controller sketch if you prefer JSON for a SPA or mobile client:
# lib/acme_web/controllers/otp_controller.ex
defmodule AcmeWeb.OtpController do
use AcmeWeb, :controller
alias Acme.Accounts.Otp
def send(conn, %{"email" => email}) do
case Otp.send_otp(email) do
{:ok, message} -> json(conn, %{message: message})
{:error, :too_soon} -> conn |> put_status(429) |> json(%{error: "too_soon"})
end
end
def verify(conn, %{"email" => email, "code" => code}) do
case Otp.verify_otp(email, code) do
:ok -> json(conn, %{ok: true})
{:error, reason} ->
status = if reason in [:locked, :too_soon], do: 429, else: 400
conn |> put_status(status) |> json(%{error: reason})
end
end
end
Per-email cooldown inside the context and per-IP throttling at the Plug / LiveView edge solve different problems—use both. Hardening checklist: Email OTP security.
Idempotency-Key: retries without double codes
Oban workers, proxies, and AI agents retry. Without Idempotency-Key, a timeout can deliver two codes that disagree in the user’s inbox.
- Header ≤ 256 characters; Rovela remembers it for 24 hours and returns the original response on replay (API: emails).
- Derive the key from the code hash (
otp-+ first 32 hex chars) so one logical mint maps to one queued message. - A deliberate resend after cooldown mints a new code → new hash → new key. Do not reuse the old key for a new code.
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-smoke-2026-11-05" \
-d '{
"from": "Acme <security@mail.acme.com>",
"to": "you@example.com",
"subject": "Your Acme code: 482913",
"html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
"text": "Your verification code is 482913. Expires in 10 minutes."
}'
Deliverability notes for auth digits (not magic URLs)
OTP mail is short and sensitive. Prefer a verified subdomain such as security@mail.example.com, aligned SPF/DKIM/DMARC, and low HTML chrome. Codes in the body (and often the subject) do not need clickable links—so click-tracking rewrites matter less than for magic links—but open/click tracking settings still belong on a domain you control.
If codes land in spam, use the checklist in Why OTP and magic-link emails go to spam. For whether your product should use digits at all versus a confirmation URL, see code vs link.
OTP codes vs magic links in Phoenix
| Six-digit OTP (this post) | Magic link | |
|---|---|---|
| User action | Type digits | Click URL |
| Secret entropy | Low → HMAC + pepper required | High URL token → SHA-256 of token is usually enough |
| Docs shape | Verification codes | Password reset / link pattern |
| Phoenix surface | LiveView send/verify or POST /auth/otp/* | POST /auth/magic/request + GET /auth/magic?token= |
| Click tracking | Optional concern | Prefer off on the security sending domain |
Both call the same POST /emails. Keep separate schemas and contexts—do not reuse an OTP row as a magic-link token. Never ship sending_access into LiveView client JS either way.
Checklist before you ship
:crypto.strong_rand_bytes+ HMAC pepper; never store raw codes.- ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP rate limits.
- Upsert invalidates the previous code; verify deletes on success.
htmlandtext; optional subject code for previews.ROVELA_API_KEY(sending_access) only in runtime config / env—never in assets or LiveView assigns.Idempotency-Keystable per logical send; new code → new key.- Uniform messaging if you hide account existence.
- Verified from-domain; spam checklist if inbox placement slips.
Related reading
- Docs: Email verification codes · POST /emails · Quick Start · Authentication.
- Product-agnostic OTP: Sending OTP codes by email · Email OTP security.
- UX choice: Email verification: code vs link.
- Deliverability: Why OTP and magic-link emails go to spam.
- Elixir transport-only sibling: Send Email with Elixir.
- Magic-link siblings (different primitive): Next.js · NestJS · FastAPI.
- Other OTP stacks: Express · Rails · Django · Go.
Bottom line: put signup/login OTP in a Phoenix context that mints CSPRNG codes, HMACs them at rest, rate-limits send/verify, and calls one Req (or Finch) POST to Rovela with an Idempotency-Key—and never put sending_access in LiveView client code or pretend the email API is your auth server.