OTP (One-Time Password) Tutorial

Copy Markdown View Source

The OTP strategy provides passwordless authentication where users receive a short code (e.g. "XKPTMH") via email or SMS, then submit it to sign in. This is similar to the magic link strategy but uses a short code instead of a URL.

Security requirements

Brute force protection

OTP codes have limited entropy by design — short codes that users can type without error. Without rate limiting, an attacker can enumerate all possible codes within the lifetime of a single OTP.

For this reason, the OTP strategy requires you to declare a brute_force_strategy at the DSL level. The verifier will fail compilation if the declared strategy is not actually wired up to the request and sign-in actions.

A 10-minute OTP lifetime with 6 uppercase letters gives ~85 million possible codes. Even so, restricting to a handful of attempts per identity per OTP lifetime is essential.

Choosing a brute force strategy

The brute_force_strategy option accepts one of:

  • :rate_limit — defers to the AshRateLimiter extension on the same resource. The verifier checks that the extension is present and that every OTP action has a rate_limit entry.
  • {:audit_log, :audit_log_name} — tracks failed attempts in an audit log add-on and blocks after audit_log_max_failures within audit_log_window.
  • {:preparation, MyApp.BruteForceMitigation} — plug in a custom Ash.Resource.Preparation implementation and take full control.

Example using rate limiting:

use Ash.Resource, extensions: [AshAuthentication, AshRateLimiter]

authentication do
  strategies do
    otp do
      identity_field :email
      brute_force_strategy :rate_limit
      sender MyApp.Accounts.User.Senders.SendOtp
    end
  end
end

rate_limit do
  backend MyApp.RateLimiterBackend

  action :request_otp,
    limit: 5,
    per: :timer.minutes(15),
    key: fn query -> "otp:request:#{query.arguments[:email]}" end

  action :sign_in_with_otp,
    limit: 5,
    per: :timer.minutes(10),
    key: fn query -> "otp:sign_in:#{query.arguments[:email]}" end
end

Scope the rate limit bucket by identity

AshRateLimiter's default bucket key is the domain + resource + action name, which means a single global bucket is shared by all callers. Without a key function, once any 5 callers hit sign_in_with_otp in 10 minutes the 6th is blocked — regardless of whose email they supplied. That both lets an attacker DoS the entire app by burning the bucket and fails to stop them from enumerating a single victim's code.

Always supply a key function that scopes by the identity_field argument, as in the example above.

Example using an audit log:

authentication do
  strategies do
    otp do
      identity_field :email
      brute_force_strategy {:audit_log, :auth_audit_log}
      audit_log_window {5, :minutes}
      audit_log_max_failures 5
      sender MyApp.Accounts.User.Senders.SendOtp
    end
  end

  add_ons do
    audit_log :auth_audit_log do
      audit_log_resource MyApp.Accounts.AuthAuditLog
    end
  end
end

The audit log add-on tracks all authentication actions by default, so there's no need to list them explicitly — failures on request_otp and sign_in_with_otp will both count toward the audit_log_max_failures threshold.

Prerequisites

Your user resource needs:

  1. A primary key
  2. A uniquely constrained identity field (e.g. email)
  3. Tokens enabled with store_all_tokens? set to true

Add the OTP strategy to the User resource

defmodule MyApp.Accounts.User do
  use Ash.Resource,
    extensions: [AshAuthentication, AshRateLimiter],
    domain: MyApp.Accounts

  attributes do
    uuid_primary_key :id
    attribute :email, :ci_string, allow_nil?: false
  end

  authentication do
    tokens do
      enabled? true
      store_all_tokens? true
      token_resource MyApp.Accounts.Token
      signing_secret MyApp.Secrets
    end

    strategies do
      otp do
        identity_field :email
        brute_force_strategy :rate_limit
        sender MyApp.Accounts.User.Senders.SendOtp
      end
    end
  end

  # Per-identity rate limiting — see "Choosing a brute force strategy" above
  # for the reasoning behind scoping the bucket by email.
  rate_limit do
    backend MyApp.RateLimiterBackend

    action :request_otp,
      limit: 5,
      per: :timer.minutes(15),
      key: fn query -> "otp:request:#{query.arguments[:email]}" end

    action :sign_in_with_otp,
      limit: 5,
      per: :timer.minutes(10),
      key: fn query -> "otp:sign_in:#{query.arguments[:email]}" end
  end

  identities do
    identity :unique_email, [:email]
  end
end

Configuration options

The strategy supports several options with sensible defaults:

otp do
  identity_field :email
  otp_lifetime {10, :minutes}          # how long the code is valid
  otp_length 6                         # length of the generated code
  otp_characters :unambiguous_uppercase # :unambiguous_uppercase, :unambiguous_alphanumeric, :digits_only, :uppercase_letters_only
  case_sensitive? false                 # when false, "xkptmh" matches "XKPTMH"
  single_use_token? true               # revoke code after successful sign-in
  sender MyApp.Accounts.User.Senders.SendOtp
end

Create an OTP sender

The sender receives the user record and the short OTP code (not a JWT). You are responsible for delivering it to the user.

Inside lib/my_app/accounts/user/senders/send_otp.ex:

defmodule MyApp.Accounts.User.Senders.SendOtp do
  @moduledoc """
  Sends a one-time password code to the user.
  """
  use AshAuthentication.Sender

  @impl AshAuthentication.Sender
  def send(user, otp_code, _opts) do
    MyApp.Accounts.Emails.deliver_otp(user.email, otp_code)
  end
end

Inside lib/my_app/accounts/emails.ex:

def deliver_otp(email, otp_code) do
  deliver(email, "Your sign-in code", """
  <html>
    <p>Your sign-in code is:</p>
    <p style="font-size: 24px; font-weight: bold; letter-spacing: 4px;">#{otp_code}</p>
    <p>This code expires in 10 minutes.</p>
  </html>
  """)
end

You can also use an inline function sender for simple cases:

sender fn user, otp_code, _opts ->
  MyApp.Accounts.Emails.deliver_otp(user.email, otp_code)
end

Registration

By default, the OTP strategy only allows existing users to sign in. To allow new users to register via OTP, set registration_enabled? to true:

otp do
  identity_field :email
  registration_enabled? true
  sender MyApp.Accounts.User.Senders.SendOtp
end

When registration is enabled:

  • The request action sends an OTP code even if no user with that email exists yet.
  • The sign-in action becomes a :create action with upsert? true. If the user doesn't exist, they are created; if they do, they are matched by their identity.
  • {:audit_log, ...} is not a valid brute_force_strategy in this mode, because audit log mitigation requires an existing user record. Use :rate_limit or {:preparation, MyModule} instead.
  • The sender receives the email address as a string (instead of a user record) when the user doesn't exist yet. Handle both cases in your sender:
def send(user_or_email, otp_code, _opts) do
  email =
    case user_or_email do
      %{email: email} -> email
      email when is_binary(email) -> email
    end

  MyApp.Accounts.Emails.deliver_otp(email, otp_code)
end

Note: If you don't define a sign-in action yourself, the strategy auto-generates the correct one at compile time and changing registration_enabled? just works. However, if you have a sign-in action defined in your resource file (whether written by hand or generated by an installer), it is not regenerated automatically. If you change registration_enabled?, you must update the action yourself: use a :create action with AshAuthentication.Strategy.Otp.SignInChange when true, or a :read action with AshAuthentication.Strategy.Otp.SignInPreparation when false. The verifier will raise if there's a mismatch.

How it works

The OTP strategy uses a deterministic JTI (JWT ID) to map short codes back to stored tokens without requiring any schema changes to your token resource. The JTI is derived from (strategy_name, user_subject, otp_code) via AshAuthentication.SHA256Provider, keeping the crypto consistent with the recovery code strategy.

Request flow:

  1. User submits their email
  2. Strategy finds the user, generates a random OTP code
  3. A JWT is created with a deterministic JTI derived from (strategy_name, user_subject, otp_code)
  4. The JWT is stored in the token resource with purpose "otp"
  5. The short code is sent to the user via the sender
  6. Returns :ok regardless of whether the user exists (never reveals user existence)

Sign-in flow:

  1. User submits their email and OTP code
  2. Strategy finds the user, recomputes the deterministic JTI from the submitted code
  3. Looks up the token by JTI and purpose "otp"
  4. If found: code is valid. Revokes the token (if single_use_token?), generates an auth JWT, returns the user
  5. If not found: authentication fails

Using the strategy programmatically

strategy = AshAuthentication.Info.strategy!(MyApp.Accounts.User, :otp)

# Request an OTP code (sends email)
:ok = AshAuthentication.Strategy.action(strategy, :request, %{
  "email" => "user@example.com"
})

# Sign in with the code
{:ok, user} = AshAuthentication.Strategy.action(strategy, :sign_in, %{
  "email" => "user@example.com",
  "otp" => "XKPTMH"
})

# The auth JWT is available in metadata
token = user.__metadata__.token

HTTP endpoints

When using AshAuthentication.Plug, the strategy automatically registers two POST routes:

POST /user/otp/request    {"user": {"email": "user@example.com"}}
POST /user/otp/sign_in    {"user": {"email": "user@example.com", "otp": "XKPTMH"}}

Character sets and entropy

The built-in character sets and the number of possible codes they produce at the default length of 6:

OptionSymbolsCodes at length 6Notes
:unambiguous_uppercase (default)21~85.8 millionA–Z minus I, L, O, S, Z
:unambiguous_alphanumeric27~387 millionabove plus 3,4,6,7,8,9
:uppercase_letters_only26~309 millionfull A–Z
:digits_only101 millionfull 0–9; only just meets the minimum at length 6

The strategy enforces a minimum of 1,000,000 possible codes at compile time (derived from NIST SP 800-63B §5.1.3.2). Configurations that fall below this threshold — such as :digits_only with otp_length less than 6 — will raise a Spark.Error.DslError at compile time.

Custom OTP generator

By default, the strategy uses AshAuthentication.Strategy.Otp.DefaultGenerator which generates cryptographically random codes from an ambiguity-reduced character set (excluding easily misread characters like I/1, O/0, S/5, Z/2).

You can supply your own generator module:

otp do
  identity_field :email
  otp_generator MyApp.Accounts.OtpGenerator
  sender MyApp.Accounts.User.Senders.SendOtp
end

The module must export generate/1 and normalize/1:

defmodule MyApp.Accounts.OtpGenerator do
  def generate(opts) do
    length = Keyword.get(opts, :length, 6)
    # your implementation here
  end

  def normalize(code) do
    String.trim(code)
  end
end

The generate/1 function receives [length: ..., characters: ...] from the strategy configuration. The normalize/1 function is called on both the generated code (during request) and the submitted code (during sign-in) to ensure consistent matching.

Security responsibility

When using a custom generator, the compile-time entropy check is skipped — the strategy cannot reason about the code space your implementation produces. It is your responsibility to ensure the generator meets your system's security requirements, including sufficient entropy, cryptographically secure randomness, and correct handling of the length and characters opts.

Magic LinkOTP
User receivesA URL with a JWTA short code (e.g. XKPTMH)
Sign-in requiresJust the token (from URL)Both identity field and code
UX patternClick link in emailEnter code on same page
RegistrationCan register new usersCan register new users (opt-in)
Interaction requiredOptional (configurable)Always (user must type code)