← Back to blog

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.

Updated November 4, 2026
Send OTP Emails in Phoenix / Elixir (2026) with a Plain HTTP API

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 in 0..999_999, zero-padded (never :rand.uniform for auth if you can avoid it; never roll your own PRNG)
  • HMAC-SHA256 of email <> ":" <> code with a server pepper (CODE_PEPPER in 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/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in runtime config—never commit it, never put it in assets/, LiveView client hooks, or window)
  • 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 actionType digitsClick URL
Secret entropyLow → HMAC + pepper requiredHigh URL token → SHA-256 of token is usually enough
Docs shapeVerification codesPassword reset / link pattern
Phoenix surfaceLiveView send/verify or POST /auth/otp/*POST /auth/magic/request + GET /auth/magic?token=
Click trackingOptional concernPrefer 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

  1. :crypto.strong_rand_bytes + HMAC pepper; never store raw codes.
  2. ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP rate limits.
  3. Upsert invalidates the previous code; verify deletes on success.
  4. html and text; optional subject code for previews.
  5. ROVELA_API_KEY (sending_access) only in runtime config / env—never in assets or LiveView assigns.
  6. Idempotency-Key stable per logical send; new code → new key.
  7. Uniform messaging if you hide account existence.
  8. Verified from-domain; spam checklist if inbox placement slips.

Related reading

  1. Docs: Email verification codes · POST /emails · Quick Start · Authentication.
  2. Product-agnostic OTP: Sending OTP codes by email · Email OTP security.
  3. UX choice: Email verification: code vs link.
  4. Deliverability: Why OTP and magic-link emails go to spam.
  5. Elixir transport-only sibling: Send Email with Elixir.
  6. Magic-link siblings (different primitive): Next.js · NestJS · FastAPI.
  7. 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.