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.

## 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) |