Skip to content

Email OTP

EmailOtpPlugin adds passwordless sign-in, email verification, password reset, and (optionally) email change via 6-digit one-time codes delivered to the user's email address. The surface mirrors better-auth's emailOTP plugin so client-side patterns transfer directly.

When enabled, the plugin contributes the following endpoints to auth.router:

Method Path Description
POST /auth/email-otp/send-verification-otp Issue + email an OTP. purpose in sign-in \| email-verification \| password-reset
POST /auth/email-otp/check-verification-otp Verify an OTP without consuming it (UX pre-check)
POST /auth/sign-in/email-otp Consume OTP → return session. Auto-registers if user is new
POST /auth/email-otp/verify-email Consume OTP → mark emailVerified=true
POST /auth/email-otp/request-password-reset Issue + email a reset OTP
POST /auth/email-otp/reset-password Consume OTP → set new password, revoke sessions
POST /auth/email-otp/request-email-change (Auth required) issue OTP for new email when email-change is enabled
POST /auth/email-otp/change-email (Auth required) consume OTP → update email when email-change is enabled

Installation

from datetime import timedelta

from pydantic import SecretStr

from fastauth import FastAuth, FastAuthOptions
from fastauth.database import memory
from fastauth.plugins.email_otp import EmailChangeOtpOptions, EmailOtpOptions
from fastauth import email_otp, email_password

auth = FastAuth(
    FastAuthOptions(
        secret_key=SecretStr("replace-me-with-your-application-secret"),
        database=memory(),
    ),
    plugins=[
        email_password(),
        email_otp(EmailOtpOptions(
            code_length=6,
            expires_in=timedelta(minutes=5),
            max_attempts=3,
            allow_sign_up=True,
            email_change=EmailChangeOtpOptions(enabled=False),
        )),
    ],
)

Every config field has a sensible default; the snippet above shows the defaults explicitly. Pass an empty EmailOtpOptions() (or simply email_otp()) to accept all defaults.

Configuration reference

EmailOtpOptions fields:

Field Default Notes
code_length 6 OTP digit count. Range 4–10 (validated at construction).
expires_in timedelta(minutes=5) Per-OTP TTL. After this the row is expired and a fresh OTP is required.
max_attempts 3 Failed verifications before the OTP row is destroyed. The user must request a new OTP.
allow_sign_up True When False, sign-in via OTP rejects unknown emails. The send endpoint silently no-ops on unknown emails to preserve anti-enumeration.
email_change.enabled False Registers /email-otp/request-email-change and /email-otp/change-email.
email_change.verify_current_email False When True, the email-change request must include an OTP previously sent to the user's current email (purpose=email-verification) before a new-email OTP is issued.

Sign-in flow

# 1. Client requests an OTP
await client.post(
    "/auth/email-otp/send-verification-otp",
    json={"email": "alice@example.com", "purpose": "sign-in"},
)
# 2. User reads the email, types the 6-digit code into your UI
# 3. Client exchanges the OTP for a session
response = await client.post(
    "/auth/sign-in/email-otp",
    json={"email": "alice@example.com", "otp": "123456", "name": "Alice"},
)
# response.json() carries {user, session, credentials?}
# A session cookie is set on the response.

If the email doesn't match an existing user, a new user is created with emailVerified=true (OTP delivery proved email ownership) and an Account row tied to ProviderId.EMAIL_OTP with no password. The name field on the request is only consulted for new users.

To restrict to existing users only, set allow_sign_up=False. The send endpoint will silently succeed on unknown emails (anti-enumeration) and the sign-in endpoint will return 401 INVALID_CREDENTIALS.

Verify email

await client.post(
    "/auth/email-otp/send-verification-otp",
    json={"email": user.email, "purpose": "email-verification"},
)
await client.post(
    "/auth/email-otp/verify-email",
    json={"email": user.email, "otp": "123456"},
)

This is the OTP-based equivalent of /auth/verify-email. Both flows coexist; pick whichever your UI prefers.

Password reset

await client.post(
    "/auth/email-otp/request-password-reset",
    json={"email": user.email},
)
await client.post(
    "/auth/email-otp/reset-password",
    json={"email": user.email, "otp": "123456", "password": "new-secure-pw-1"},
)

The reset endpoint revokes every active session for the user (same behaviour as the token-based /auth/reset-password). If the user originally signed up via OTP and never set a password, the reset flow creates the credential Account row for them; subsequent sign-ins via /auth/sign-in/email will work with the new password.

Change email

Set email_change=EmailChangeOtpOptions(enabled=True) to register the change-email pair. Without an authenticated session both endpoints return 401.

# Request OTP for the new email
await client.post(
    "/auth/email-otp/request-email-change",
    json={"newEmail": "new@example.com"},
    cookies=session_cookies,
)
# Confirm with the OTP delivered to the new email
await client.post(
    "/auth/email-otp/change-email",
    json={"newEmail": "new@example.com", "otp": "123456"},
    cookies=session_cookies,
)

For added security, set EmailChangeOtpOptions(verify_current_email=True). The request endpoint then requires a second OTP that the client must have separately obtained via send-verification-otp with purpose=email-verification. This double-confirms the change is initiated by someone who controls both the current and new email addresses, defending against the case where an attacker has temporarily-active session cookies.

# 1. Send an OTP to the user's current email first
await client.post(
    "/auth/email-otp/send-verification-otp",
    json={"email": current_email, "purpose": "email-verification"},
    cookies=session_cookies,
)
# 2. Submit the change request with that OTP, plus the new email
await client.post(
    "/auth/email-otp/request-email-change",
    json={"newEmail": "new@example.com", "otpForCurrent": "123456"},
    cookies=session_cookies,
)
# 3. Then confirm with the OTP delivered to the new email
await client.post(
    "/auth/email-otp/change-email",
    json={"newEmail": "new@example.com", "otp": "654321"},
    cookies=session_cookies,
)

Pre-checking an OTP

POST /auth/email-otp/check-verification-otp verifies an OTP without consuming it. Useful for "submit your code" forms that want to display a "correct so far" indicator before posting to the consume endpoint.

await client.post(
    "/auth/email-otp/check-verification-otp",
    json={"email": user.email, "purpose": "sign-in", "otp": "123456"},
)

The check endpoint does increment the per-OTP failure counter on incorrect codes — a cap is necessary regardless of which endpoint receives the wrong code. It does not feed the global account lockout, so a UX pre-check that finds a typo doesn't risk locking the account.

Security notes

  • OTPs are stored hashed. The Verification.value_hash column contains the SHA-256 of the plaintext OTP; the plain code is never persisted. A database breach therefore doesn't leak live OTPs. As a consequence, the "reuse" resend strategy from better-auth is not available — every send issues a fresh OTP and invalidates any prior un-consumed code (the "rotate" strategy).
  • Per-OTP attempt cap. Each OTP row carries an attempt_count. When it equals max_attempts the row is deleted; the user must request a fresh OTP.
  • Lockout coupling. Failed sign-in / verify-email / reset / change OTP attempts feed AccountLockoutTracker.record_failure(identifier) exactly as failed password attempts do. Five OTP failures in 15 minutes (or whatever your LockoutConfig says) lock the account just like five wrong passwords would.
  • Anti-enumeration on sends. The send endpoint always returns {"success": true}, regardless of whether the email matches an existing user. This holds for email-verification and password-reset where the recipient must already be a user, and for sign-in when allow_sign_up=False.
  • One-time use. A successful verification deletes the row; the same OTP cannot be replayed for a second action.
  • Rate limiting. The plugin contributes per-IP rate-limit rules:
  • 3/min on send-verification-otp and request-password-reset
  • 10/min on check-verification-otp, sign-in, verify-email, reset-password. These compose with the global RateLimitOptions.

Audit events

The plugin publishes three new events that audit_logs() will auto-capture:

  • OtpRequested(identifier, purpose) — emitted on every issuance. No plaintext OTP.
  • OtpVerified(identifier, purpose, user_id) — emitted on every successful verify.
  • OtpVerifyFailed(identifier, purpose, attempt_count) — emitted on every failed verify.

The internal OtpGenerated(identifier, purpose, plain) event is also published (carrying the plaintext) but is filtered out of audit logs automatically — it exists for TestUtilsPlugin.get_otp(...) to capture codes during integration tests.

Capturing OTPs in tests

from pydantic import SecretStr

from fastauth import FastAuth, FastAuthOptions
from fastauth.database import memory
from fastauth.plugins.test_utils import TestUtilsOptions
from fastauth import email_otp, test_utils

auth = FastAuth(
    FastAuthOptions(
        secret_key=SecretStr("replace-me-with-your-application-secret"),
        database=memory(),
    ),
    plugins=[
        email_otp(),
        test_utils(TestUtilsOptions(capture_otp=True)),
    ],
)

# Trigger send
await client.post("/auth/email-otp/send-verification-otp",
                 json={"email": "alice@example.com", "purpose": "sign-in"})
# Read it back
helpers = auth.context.plugins.by_id["fastauth-test-utils"].helpers
otp = helpers.get_otp("alice@example.com")

test_utils() subscribes to OtpGenerated and stores plaintext OTPs in memory keyed by identifier. Only enable capture_otp=True in tests — production deployments should not capture plaintext codes.

EmailOtpOptions.expires_in accepts timedelta, numeric seconds, or a compact duration string such as "10m". Verification, password-reset, and email-change OTP messages use the corresponding subjects configured in FastAuthOptions; OTP sign-in keeps its dedicated "Your sign-in code" subject.