OTP
Official plugin — one-time codes over SMS, WhatsApp, or email under /auth, with explicit provider or app mode.
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.
The one rule
Enable gate.auth, then .plug(otp({ mode: "provider" })) or
.plug(otp({ mode: "app", channels: [...] })). Never omit mode. Never log
raw OTPs.
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().
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.
Quick start
Plug app mode (multi-channel)
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
}),
);Request a code
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.
Resend on another channel (app mode only)
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.
Verify
const { data } = await api.auth.verifyOtp({
phone: "+15551234567",
otp,
});POST /auth/otp/verify — five failed attempts consume the challenge.
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.
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) |
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.
Provider mode setup
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
You omitted mode. Set mode: "provider" or mode: "app" explicitly — OKE
never infers which mechanism you meant.
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: [...] }).
Wait for resendCooldownMs (default 60s). The challenge TTL is unchanged —
only delivery is rate-limited.
In local / test the console driver captures messages. Use
exposeDevOtp: true for unit tests without a real provider.
Learn more
- Magic link — link instead of a code
- Two-factor — email OTP / TOTP as a second factor after password (distinct from this primary
/auth/otpsign-in) - Channel —
fx.send,fx.sendOtp, drivers, Mailpit - Gate —
gate.auth