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
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
resendCooldownSecondsfails withOTP_COOLDOWNand aretryAfter. - After
maxAttemptswrong codes the pending code is deleted and the identifier is locked forlockoutSeconds(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.
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:
sendanswers202 { "sent": true }for every well formed request. Cooldown, lockout, delivery failure and acanSendrefusal look the same, so the route cannot be used to probe for accounts. You lose theRetry-Afterhint on send as a result.verifyanswers400 { "error": "Invalid or expired code" }for wrong, expired and unknown codes. It answers429withRetry-Afteronly 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
sendRateLimitandverifyRateLimit). - Only
sign-inandverify-emailare reachable by default (purposeswidens this). Reset and 2FA codes go through their own modules below.
Two-factor method
Passotp to twoFactor to add an email or SMS code next to TOTP. TOTP routes are unchanged.
Password reset by code
SetpasswordReset.otp to offer a code instead of a link. The link flow keeps working.
auth.passwordReset.requestResetOtp(email) and resetPasswordWithOtp(email, code, password). Successful resets revoke sessions like the link flow does.