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_hashcolumn 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 equalsmax_attemptsthe 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 yourLockoutConfigsays) 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 foremail-verificationandpassword-resetwhere the recipient must already be a user, and forsign-inwhenallow_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-otpandrequest-password-reset - 10/min on
check-verification-otp,sign-in,verify-email,reset-password. These compose with the globalRateLimitOptions.
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.