> ## Documentation Index
> Fetch the complete documentation index at: https://docs.theauth.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Unified OTP

> One service for email and SMS one-time codes with pluggable senders, resend cooldown, attempt lockout and constant-time checks, for sign-in, email verification, password reset and 2FA step-up.

`createOtpService` is the shared engine behind code-based flows. It does not create sessions or users. You call it, then do whatever the verified code unlocks. The older [email OTP](/auth/email-otp) and [phone](/auth/phone) modules keep working and now also take `resendCooldownSeconds`.

## Setup

```typescript title="lib/otp.ts" theme={"dark"}
import {
  createOtpService,
  emailOtpSender,
  twilioOtpSender,
  resend,
} from '@glinr/theauth/auth';

export const otp = createOtpService({
  secret: process.env.OTP_SECRET!, // keys the code hash
  senders: {
    email: emailOtpSender(resend({ apiKey: process.env.RESEND_API_KEY!, from: 'Acme <auth@acme.dev>' })),
    sms: twilioOtpSender({
      accountSid: process.env.TWILIO_ACCOUNT_SID!,
      authToken: process.env.TWILIO_AUTH_TOKEN!,
      from: process.env.TWILIO_FROM!,
    }),
  },
  resendCooldownSeconds: 60,
  maxAttempts: 5,
  lockoutSeconds: 900,
});
```

## Send and verify

```typescript theme={"dark"}
const sent = await otp.send({ purpose: 'sign-in', channel: 'email', identifier: 'ada@example.com' });
if (!sent.success && sent.error.code === 'OTP_COOLDOWN') {
  // sent.error.details.retryAfter is in seconds
}

const checked = await otp.verify({ purpose: 'sign-in', identifier: 'ada@example.com', code: '482913' });
if (checked.success) {
  // open a session, mark the email verified, allow the password change, ...
}
```

Purposes are `sign-in`, `verify-email`, `reset-password` and `two-factor`. A code only verifies for the purpose it was sent for, so a sign-in code cannot reset a password. For 2FA step-up, send a `two-factor` code to a user who is already signed in, then require a successful `verify` before the sensitive action.

## Behavior

* Codes are digits drawn with rejection sampling and stored only as an HMAC (or SHA-256 without `secret`) bound to purpose and identifier.
* Comparison is constant time.
* A new send inside `resendCooldownSeconds` fails with `OTP_COOLDOWN` and a `retryAfter`.
* After `maxAttempts` wrong codes the pending code is deleted and the identifier is locked for `lockoutSeconds` (`OTP_LOCKED`). The right code does not unlock early.
* A failed delivery removes the code and returns `OTP_SEND_FAILED`, so a provider outage does not start a cooldown.
* Verifying consumes the code.

State lives in a [secondary storage](/secondary-storage). The default is in memory, which is right for development and single-process servers. Pass `storage` (Redis, KV or database storage) when you run more than one instance. Counters are read and written without a transaction, so under heavy concurrency a few extra guesses can slip in before the lockout lands.

## Senders

| Sender | Channel | Notes |
| - | - | - |
| `emailOtpSender(provider)` | email | Wraps any email provider: `resend`, `ses`, `postmark`, `sendgrid`, `smtp` |
| `twilioOtpSender(config)` | sms | Raw `fetch`, give `from` or `messagingServiceSid` |
| `consoleOtpSender()` | either | Prints the code. Development only |

A sender is `{ send(message): Promise<void> }`, so adding another provider takes a few lines. `emailOtpSender` accepts `subject` and `render` to change the message text.

## HTTP routes

Nothing is mounted until you register the plugin. `otpRoutes` adds two endpoints:

```typescript theme={"dark"}
import { createTheAuth } from '@glinr/theauth';
import { consoleOtpSender, createOtpService, otpRoutes } from '@glinr/theauth/auth';

const otp = createOtpService({ senders: { email: consoleOtpSender() }, secret: process.env.OTP_SECRET });

const auth = await createTheAuth({
  database: { provider: 'sqlite', url: 'theauth.db' },
  auth: { session: { secret: process.env.SESSION_SECRET! } },
  plugins: [
    otpRoutes({
      service: otp,
      // Skip delivery for identifiers with no account. The caller still sees 202.
      canSend: async ({ identifier }) => (await findUserByEmail(identifier)) !== null,
      // Open a session or mark the email verified here. The result is merged into the 200 body.
      onVerified: async ({ identifier }) => ({ email: identifier }),
    }),
  ],
});
```

```bash theme={"dark"}
curl -X POST localhost:3000/auth/code/send   -d '{"identifier":"ada@example.com"}'
curl -X POST localhost:3000/auth/code/verify -d '{"identifier":"ada@example.com","code":"482913"}'
```

* `send` answers `202 { "sent": true }` for every well formed request. Cooldown, lockout, delivery failure and a `canSend` refusal look the same, so the route cannot be used to probe for accounts. You lose the `Retry-After` hint on send as a result.
* `verify` answers `400 { "error": "Invalid or expired code" }` for wrong, expired and unknown codes. It answers `429` with `Retry-After` only after the identifier is locked, which does not depend on whether an account exists.
* Both are rate limited per client IP (defaults 5 and 10 per minute, change with `sendRateLimit` and `verifyRateLimit`).
* Only `sign-in` and `verify-email` are reachable by default (`purposes` widens this). Reset and 2FA codes go through their own modules below.

## Two-factor method

Pass `otp` to `twoFactor` to add an email or SMS code next to TOTP. TOTP routes are unchanged.

```typescript theme={"dark"}
twoFactor({
  otp: {
    service: otp,
    resolveContact: async (userId) => ({ channel: 'email', identifier: await emailFor(userId) }),
  },
});
// POST /auth/2fa/otp/send    (signed in) -> 202
// POST /auth/2fa/otp/verify  { code }    -> { valid: true } | 400 | 429
```

## Password reset by code

Set `passwordReset.otp` to offer a code instead of a link. The link flow keeps working.

```typescript theme={"dark"}
passwordReset: {
  sendResetEmail, resetUrl,        // existing link flow
  otp: { service: otp },           // adds the code flow
}
// POST /auth/forgot-password/otp  { email }                    -> 204 always
// POST /auth/reset-password/otp   { email, code, password }    -> 204 | 400 | 429
```

The same thing is available in code as `auth.passwordReset.requestResetOtp(email)` and `resetPasswordWithOtp(email, code, password)`. Successful resets revoke sessions like the link flow does.

## Existing modules

```typescript theme={"dark"}
emailOtp({ sendOtp, resendCooldownSeconds: 60 });
// POST /auth/otp/send now answers 429 with a Retry-After header inside the window.
```

The email OTP module also compares hashes in constant time now.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.