Skip to main content
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 and phone modules keep working and now also take resendCooldownSeconds.

Setup

lib/otp.ts

Send and verify

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. 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

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:
  • 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.

Password reset by code

Set passwordReset.otp to offer a code instead of a link. The link flow keeps working.
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

The email OTP module also compares hashes in constant time now.
Last modified on October 9, 2026