Email OTP Plugin#
Overview#
The emailOTP plugin provides OTP-based verification flows for email addresses, covering sign-in, email verification, password reset, and email change. It is registered at plugin ID "email-otp" and exposes 11 endpoints backed by a shared verification table via internalAdapter.
Primary source files:
packages/better-auth/src/plugins/email-otp/routes.ts— all endpoint handlerspackages/better-auth/src/plugins/email-otp/utils.ts— identifier construction and helperspackages/better-auth/src/plugins/email-otp/otp-token.ts— OTP storage, verification, and reuse logicpackages/better-auth/src/plugins/email-otp/types.ts— configuration type definitionspackages/better-auth/src/plugins/email-otp/index.ts— plugin registration and rate-limit rules
Identifier Construction#
All stored OTPs are keyed by a compound identifier built by toOTPIdentifier:
{type}-otp-{email}
where type is one of "email-verification", "sign-in", "forget-password", or "change-email" .
change-email is the exception — compound two-email identifier. Instead of a single email, the identifier encodes both the current and new email as {type}-otp-{currentEmail}-{newEmail} . This ties the OTP to both addresses, so it cannot be replayed if either address changes.
All other types use the single-email form.
Endpoint Inventory#
| HTTP | Path | Client Method | Purpose |
|---|---|---|---|
| POST | /email-otp/send-verification-otp | authClient.emailOtp.sendVerificationOtp | Generic send — email + type (not change-email) |
| POST | /sign-in/email-otp | authClient.signIn.emailOtp | Verify OTP and sign in (or create user) |
| POST | /email-otp/verify-email | authClient.emailOtp.verifyEmail | Mark email as verified |
| POST | /email-otp/request-password-reset | authClient.emailOtp.requestPasswordReset | Send password-reset OTP |
| POST | /email-otp/reset-password | authClient.emailOtp.resetPassword | Verify OTP + set new password |
| POST | /email-otp/request-email-change | authClient.emailOtp.requestEmailChange | Initiate email change (sends OTP to new email) |
| POST | /email-otp/change-email | authClient.emailOtp.changeEmail | Verify OTP + commit email change |
| POST | /email-otp/check-verification-otp | auth.api.checkVerificationOTP | Server-only: validate OTP without side effects |
| POST | /email-otp/create-verification-otp | auth.api.createVerificationOTP | Server-only: generate and store an OTP |
| GET | /email-otp/get-verification-otp | auth.api.getVerificationOTP | Server-only: retrieve current OTP (not available when storeOTP: "hashed") |
| POST | /forget-password/email-otp | — | Deprecated — use /email-otp/request-password-reset |
Generic vs. type-specific endpoints#
/email-otp/send-verification-otp is the generic send endpoint: it accepts any type except "change-email" — passing change-email there is explicitly rejected with a BAD_REQUEST error and a hint to use the dedicated endpoint . The change-email flow has its own pair of endpoints (request-email-change / change-email) because it requires an active session, a two-address identifier, and optional current-email verification before sending to the new address.
Change-Email Flow#
The change-email flow is behind a feature flag (opts.changeEmail.enabled) and requires a sensitiveSessionMiddleware on both endpoints .
Step 1 — POST /email-otp/request-email-change :
- Optionally verifies the current email with an OTP first, if
changeEmail.verifyCurrentEmail: true. This OTP uses the single-email identifier (email-verification-otp-{currentEmail}). - Stores a new OTP under the compound identifier
change-email-otp-{currentEmail}-{newEmail}. - If the new email already belongs to an existing user, silently deletes the OTP and returns
success: true(enumeration protection) . - Sends OTP to
newEmailviasendVerificationOTP.
Step 2 — POST /email-otp/change-email :
- Verifies OTP atomically against the compound identifier.
- Confirms new email is still unoccupied .
- Updates the user record (
email = newEmail,emailVerified = true) and firesbeforeEmailVerification/afterEmailVerificationhooks . - Refreshes the session cookie with the new email .
OTP Storage & Security#
Stored value format: {storedOTP}:{attemptCount} — the attempt counter is embedded in-band . splitAtLastColon is used everywhere to parse this.
storeOTP option controls how the OTP is persisted :
| Mode | Behavior |
|---|---|
"plain" (default) | Raw OTP stored as-is |
"encrypted" | Symmetric encryption using secretConfig; recoverable for reuse strategy |
"hashed" | SHA-256 + base64url; not recoverable — getVerificationOTP and resendStrategy: "reuse" are unavailable |
{ hash, encrypt, decrypt } | Custom implementations |
All comparisons use constantTimeEqual to prevent timing attacks .
Atomic verification#
Critical endpoints (sign-in, verify-email, reset-password, change-email) call atomicVerifyOTP:
- Pre-check: read record and throw
OTP_EXPIREDbefore consuming (for better error messages). - Atomically consume via
consumeVerificationValue— first concurrent caller wins; concurrent racers getnull→INVALID_OTP. - If budget exhausted (
attempts >= allowedAttempts, default 3): reject, do not recreate . - If wrong code: recreate record with incremented attempts and original expiry .
/email-otp/check-verification-otp uses a non-atomic path instead (read-then-update), so it is not suitable for production verification gating .
Resend Strategy#
Controlled by resendStrategy option :
"rotate"(default): Always generates a fresh OTP, overwriting any existing one."reuse": If a non-expired, non-exhausted OTP exists and is recoverable (not hashed),tryReuseOTPrefreshes its expiry and returns the existing code. Falls back torotateif the OTP is hashed or expired.
Configuration Reference#
Defined in types.ts:
| Option | Default | Notes |
|---|---|---|
sendVerificationOTP | required | Delivery callback ({ email, otp, type }, ctx) => void |
otpLength | 6 | Length of generated numeric OTP |
expiresIn | 300 (sec) | TTL |
allowedAttempts | 3 | Wrong-guess limit before lockout |
storeOTP | "plain" | See storage modes above |
resendStrategy | "rotate" | "reuse" re-sends existing OTP |
disableSignUp | false | Block auto sign-up on first OTP sign-in |
generateOTP | numeric random | Override OTP generation |
changeEmail.enabled | false | Enable email-change flow |
changeEmail.verifyCurrentEmail | — | Require OTP on current email before sending to new |
Rate limits default to 3 requests per 60-second window across all plugin endpoints .