Skip to content

Email OTP ​

One-time codes by email for sign-in, email verification, email change and password reset. Mirrors the TS emailOTP() plugin.

Enable ​

python
from better_auth import BetterAuth
from better_auth.plugins_ext import EmailOTPPlugin

async def send_verification_otp(email, otp, otp_type):
    ...  # send the code by email

auth = BetterAuth(
    secret="a-strong-32-character-minimum-secret",
    plugins=[EmailOTPPlugin(send_verification_otp=send_verification_otp)],
)

Options ​

OptionTypeDefaultDescription
send_verification_otpcallablerequired(email, otp, type) -> None, delivers the code.
otp_lengthint6Number of digits.
expires_inint300Code lifetime in seconds.
generate_otpcallable | NoneNoneCustom code generator.
send_verification_on_sign_upboolFalseSend an email-verification OTP after sign-up.
disable_sign_upboolFalseNever create a user implicitly from an OTP sign-in.
allowed_attemptsint3Wrong-code budget per stored OTP.
store_otpstr | dict"plain""plain", "hashed", "encrypted", or a custom hash/encrypt config.
resend_strategystr"rotate"What a resend does to the pending code.
change_emaildict | NoneNoneEmail-change sub-options.
override_default_email_verificationboolFalseReplace the core link-based email verification with OTP emails.
rate_limitdict[str, int] | NoneNonePer-endpoint rate-limit overrides.

Endpoints ​

9 routes:

MethodPath
POST/email-otp/send-verification-otp
POST/email-otp/check-verification-otp
POST/email-otp/verify-email
POST/sign-in/email-otp
POST/email-otp/request-password-reset
POST/forget-password/email-otp
POST/email-otp/reset-password
POST/email-otp/request-email-change
POST/email-otp/change-email

The TS server-only endpoints createVerificationOTP / getVerificationOTP are not mounted as HTTP routes (their paths 404). They are exposed as plain async methods on the plugin instance: create_verification_otp(email, otp_type) and get_verification_otp(email, otp_type).

Schema ​

No extra tables: codes live in the core verification table, identifier scheme <type>-otp-<email>, value "<storedOTP>:<attempts>", byte-compatible with TS.

Notes ​

  • The send endpoint is origin-checked, so a cookieless cross-origin POST cannot mail a code to an arbitrary address.
  • Sign-in codes for unknown emails are silently dropped (no user enumeration).
  • get_verification_otp raises a 400 when store_otp is hashed: the plain text is unrecoverable.
  • Codes are consumed atomically: one code can never satisfy two verifications.
  • Signing in an existing unverified user with /sign-in/email-otp deletes every account linked to it (the password and any OAuth links) and all of its sessions, then marks the email verified. The response returns the user with emailVerified: true.
  • When user.validate_user_info is set, it runs before /sign-in/email-otp creates a new user, with source set to {"action": "create-user", "method": "email-otp"}. A refusal is a 403 with the hook's error code.

Changed in 1.1

Before 1.1, only the password account was removed when an unverified user signed in with an email OTP. OAuth links are now deleted too. See Upgrade from 1.0.

MIT licensed · API-compatible with better-auth