Packages
ash_authentication
5.0.0-rc.10
5.0.0-rc.12
5.0.0-rc.11
5.0.0-rc.10
5.0.0-rc.9
5.0.0-rc.8
5.0.0-rc.7
5.0.0-rc.6
5.0.0-rc.5
5.0.0-rc.4
5.0.0-rc.3
5.0.0-rc.2
5.0.0-rc.1
5.0.0-rc.0
4.14.1
4.14.0
4.13.7
4.13.6
4.13.5
4.13.4
4.13.3
4.13.2
retired
4.13.1
retired
4.13.0
4.12.0
4.11.0
4.10.0
4.9.9
4.9.8
4.9.7
4.9.6
4.9.5
4.9.4
4.9.3
4.9.2
4.9.1
4.9.0
4.8.7
4.8.6
4.8.5
4.8.3
4.8.2
4.8.1
4.8.0
4.7.6
4.7.5
4.7.4
4.7.3
4.7.2
4.7.1
4.7.0
4.6.4
4.6.3
4.6.2
4.6.1
4.6.0
4.5.6
4.5.5
4.5.4
4.5.3
4.5.2
4.5.1
4.5.0
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.12
4.3.11
4.3.10
4.3.9
4.3.8
4.3.7
4.3.6
4.3.5
4.3.4
4.3.3
4.3.2
4.3.1
4.3.0
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.0
4.0.4
4.0.3
4.0.2
4.0.1
4.0.0
4.0.0-rc.6
4.0.0-rc.5
4.0.0-rc.3
4.0.0-rc.2
4.0.0-rc.1
4.0.0-rc.0
3.12.4
3.12.3
3.12.2
3.12.1
3.12.0
3.11.16
3.11.15
3.11.14
3.11.13
3.11.12
3.11.11
3.11.10
3.11.9
3.11.8
3.11.7
3.11.6
3.11.5
3.11.4
3.11.3
3.11.2
3.11.1
3.11.0
3.10.8
3.10.7
3.10.6
3.10.5
3.10.4
3.10.3
3.10.2
3.10.1
3.10.0
3.9.6
3.9.5
3.9.4
3.9.3
3.9.2
3.9.1
3.9.0
3.8.0
3.7.9
3.7.8
3.7.6
3.7.5
3.7.4
3.7.3
3.7.2
3.7.1
3.7.0
3.6.1
3.6.0
3.5.3
3.5.2
3.5.1
3.5.0
3.3.1
3.3.0
3.2.2
3.2.1
3.2.0
3.1.0
3.0.3
Authentication extension for the Ash Framework.
Current section
Files
Jump to
Current section
Files
documentation/tutorials/otp.md
<!--
SPDX-FileCopyrightText: 2026 Alembic Pty Ltd
SPDX-License-Identifier: MIT
-->
# OTP (One-Time Password) Tutorial
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.
> #### Time-based one-time passwords {: .tip}
>
> If you're looking for the six-digit numeric codes which periodically change - sometimes
> called Google Authenticator - you almost certainly want [TOTP](tutorials/totp.md) instead.
## Security requirements
> #### Brute force protection {: .warning}
>
> 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`](https://hexdocs.pm/ash_rate_limiter)
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:
```elixir
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 {: .warning}
>
> `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:
```elixir
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
```elixir
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:
```elixir
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`:
```elixir
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`:
```elixir
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:
```elixir
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`:
```elixir
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:
```elixir
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
```elixir
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:
| Option | Symbols | Codes at length 6 | Notes |
|---|---|---|---|
| `:unambiguous_uppercase` (default) | 21 | ~85.8 million | A–Z minus I, L, O, S, Z |
| `:unambiguous_alphanumeric` | 27 | ~387 million | above plus 3,4,6,7,8,9 |
| `:uppercase_letters_only` | 26 | ~309 million | full A–Z |
| `:digits_only` | 10 | 1 million | full 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:
```elixir
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`:
```elixir
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 {: .warning}
>
> 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.
## Differences from Magic Links
| | Magic Link | OTP |
|---|---|---|
| **User receives** | A URL with a JWT | A short code (e.g. `XKPTMH`) |
| **Sign-in requires** | Just the token (from URL) | Both identity field and code |
| **UX pattern** | Click link in email | Enter code on same page |
| **Registration** | Can register new users | Can register new users (opt-in) |
| **Interaction required** | Optional (configurable) | Always (user must type code) |