`otp()` signs people in with a one-time code. You must set `mode` — there is no
auto-detect. Provider mode is SMS Verify via the bound driver; app mode is
app-owned delivery across the channels you declare.

<Callout title="The one rule">
  Enable `gate.auth`, then `.plug(otp({ mode: "provider" }))` or
  `.plug(otp({ mode: "app", channels: [...] }))`. Never omit `mode`. Never log
  raw OTPs.
</Callout>

<Callout type="info" title="One otp() per app">
  Provider and app mode cannot both be active — they claim the same fixed `/auth/otp/*` routes. Need
  two OTP-like mechanisms? Combine `otp()` with a different plugin (e.g. `magicLink()`), not a
  second `otp()`.
</Callout>

<Callout type="info" title="fx.sendOtp vs otp()">
  `fx.sendOtp` / `fx.verifyOtp` are raw Channel capabilities — no `.plug()`. Direct use means you
  build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path; skip
  it and call the raw methods in your own flow without losing the provider connection.
</Callout>

## Quick start

<Steps>

<Step>
### Plug app mode (multi-channel)

```typescript title="src/app.ts"
import { oke } from "okengine";
import { otp } from "okengine/plugins";

export const app = oke({
  name: "shop",
  env: "dev",
  gate: { auth: {} },
}).plug(
  otp({
    mode: "app",
    channels: ["sms", "whatsapp", "email"],
    exposeDevOtp: true, // local DX only
  }),
);
```

</Step>

<Step>
### Request a code

```typescript
const { data } = await api.auth.requestOtp({
  phone: "+15551234567",
  email: "ali@example.com",
});
```

`POST /auth/otp/request`. Prior active challenges for that principal are
invalidated. Delivery follows `channels` order for addresses you pass.

</Step>

<Step>
### Resend on another channel (app mode only)

```typescript
const { data } = await api.auth.resendOtp({
  phone: "+15551234567",
  email: "ali@example.com",
  channel: "email",
});
```

Same code, same TTL. Default cooldown is 60 seconds. Provider mode has no
resend surface — the provider owns the code.

</Step>

<Step>
### Verify

```typescript
const { data } = await api.auth.verifyOtp({
  phone: "+15551234567",
  otp,
});
```

`POST /auth/otp/verify` — five failed attempts consume the challenge.

</Step>

</Steps>

<Callout type="info" title="Pre-account hijack defense (email)">
  App-mode email verify proves inbox ownership. An **unverified** parked email+password account is
  reclaimed (sessions revoked, hashes cleared, `emailVerified` set) before the OTP session. Verified
  owners re-auth only.
</Callout>

## Modes

|                      | Provider mode                 | App mode                                      |
| -------------------- | ----------------------------- | --------------------------------------------- |
| Config               | `otp({ mode: "provider" })`   | `otp({ mode: "app", channels: [...] })`       |
| Who owns the code    | Provider (Verify API)         | Your app                                      |
| Delivery             | `fx.sendOtp` / `fx.verifyOtp` | `fx.deliverOtp` (Channel templates)           |
| Channels             | SMS only                      | `sms` · `whatsapp` · `email` (declared order) |
| Resend other channel | Impossible                    | `POST /auth/otp/resend`                       |
| `exposeDevOtp`       | Forbidden                     | Optional (default off)                        |

<Callout type="warn" title="Provider mode limitation">
  Resend-via-different-channel is impossible in provider mode — the code value is never visible to
  OKE. Use app mode when you need SMS → email fallback for the same code.
</Callout>

### Provider mode setup

```typescript title="oke.config.ts"
export default {
  drivers: {
    channel: {
      sms: { test: "console", prod: "taqnyat" },
    },
  },
};
```

Boot fails loudly if no SMS driver exposes `sendOtp` / `verifyOtp`. Switch to
app mode, or bind a Verify-capable driver (for example `taqnyat`).

### App mode delivery

| Concern         | Behavior                                                                                                                    |
| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Storage         | SHA-256 hash for verify + sealed AES-GCM copy (HKDF `oke-otp-seal-v1`) for redelivery                                       |
| Seal lifetime   | Wiped on verify, lockout, or TTL expiry — never left after the challenge dies                                               |
| Challenge TTL   | Default 10 minutes (`ttlMs`)                                                                                                |
| Resend cooldown | Default 60 seconds (`resendCooldownMs`) — separate from TTL                                                                 |
| Auto failover   | On real provider send errors, sently `FallbackTransport` walks remaining media; Taqnyat WhatsApp may use `sendWithFailover` |
| User resend     | Explicit `resend` with `channel` — not automatic                                                                            |

Templates: `auth-otp-email`, `auth-otp-sms`, `auth-otp-whatsapp` (EN/AR,
`{{otp}}`). SMS here is a plain message — not Taqnyat Verify.

## Options

| Option             | Type                             | Default                    | Meaning                             |
| ------------------ | -------------------------------- | -------------------------- | ----------------------------------- |
| `mode`             | `"provider" \| "app"`            | required                   | Delivery mechanism — no auto        |
| `channels`         | `("sms"\|"whatsapp"\|"email")[]` | required in app mode       | Build-time preferred order          |
| `ttlMs`            | `number`                         | 10m                        | Challenge lifetime                  |
| `resendCooldownMs` | `number`                         | 60s                        | App-mode resend spacing             |
| `exposeDevOtp`     | `boolean`                        | `false`                    | App mode only — raw OTP in response |
| `from`             | `string`                         | `OKE <no-reply@oke.local>` | Email template From                 |
| `secret`           | `string`                         | active\*                   | Auth secret (\*from `gate.auth`)    |
| `sessions`         | `SessionStore`                   | active\*                   | Session store                       |
| `identities`       | `IdentityStore`                  | new                        | Email → user                        |
| `phones`           | `PhoneStore`                     | new                        | Phone → user                        |
| `verifications`    | `VerificationStore`              | new                        | Challenge store                     |

## Surfaces

| Flow              | Path                     | Gate                     | Mode          |
| ----------------- | ------------------------ | ------------------------ | ------------- |
| `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | both          |
| `auth.verifyOtp`  | `POST /auth/otp/verify`  | `gate.public` + otp rate | both          |
| `auth.resendOtp`  | `POST /auth/otp/resend`  | `gate.public` + otp rate | app mode only |

## Troubleshooting

<Accordions>
<Accordion title='otp(): mode is required'>

You omitted `mode`. Set `mode: "provider"` or `mode: "app"` explicitly — OKE
never infers which mechanism you meant.

</Accordion>
<Accordion title="Boot fails in provider mode">

No Verify-capable SMS driver is bound. Set `drivers.channel.sms` to `taqnyat`
(or another driver with `sendOtp`/`verifyOtp`), or switch to
`otp({ mode: "app", channels: [...] })`.

</Accordion>
<Accordion title="resend_cooldown">

Wait for `resendCooldownMs` (default 60s). The challenge TTL is unchanged —
only delivery is rate-limited.

</Accordion>
<Accordion title="No email / SMS with the code (app mode)">

In `local` / `test` the `console` driver captures messages. Use
`exposeDevOtp: true` for unit tests without a real provider.

</Accordion>
</Accordions>

## Learn more

- [Magic link](/docs/plugins/magic-link) — link instead of a code
- [Two-factor](/docs/plugins/two-factor) — email OTP / TOTP as a **second** factor after password (distinct from this primary `/auth/otp` sign-in)
- [Channel](/docs/elements/channel) — `fx.send`, `fx.sendOtp`, drivers, Mailpit
- [Gate](/docs/elements/gate) — `gate.auth`

## Next

<Cards>
  <Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
  <Card
    title="Two-factor"
    description="TOTP / email OTP second factor."
    href="/docs/plugins/two-factor"
  />
  <Card title="Channel" description="Delivery drivers and Mailpit." href="/docs/elements/channel" />
</Cards>
