Email & OTP
The primary sign-in method is passwordless email OTP. Users enter their email, receive a 6-digit code, and enter it to authenticate. The same flow handles both login and signup – if the email doesn't exist, Better Auth creates the account automatically.
Server Configuration
The emailOTP plugin is configured in apps/api/lib/auth.ts:
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
await sendOTP(env, { email, otp, type });
},
otpLength: 6,
expiresIn: 300, // 5 minutes
allowedAttempts: 3, // max wrong guesses before code is invalidated
}),OTP codes are stored in the verification table and automatically expire. After 3 failed attempts, the code is invalidated and the user must request a new one.
Email Delivery
OTP emails are sent via React Email templates rendered to HTML + plain text, delivered through Resend:
// apps/api/lib/email.ts
export async function sendOTP(env, { email, otp, type }) {
// In development, OTP is also printed to the console
if (env.ENVIRONMENT === "development") {
console.log(`OTP code for ${email}: ${otp}`);
}
const component = OTPEmail({ otp, type, appName: env.APP_NAME });
const html = await renderEmailToHtml(component);
const text = await renderEmailToText(component);
return sendEmail(env, {
to: email,
subject: `Your Sign In code`,
html,
text,
});
}TIP
During local development, OTP codes are also logged to the terminal for convenience. The send callback still awaits Resend, so configure a valid API key even when you read the code from the terminal.
Client Flow
The auth form implements a 3-step state machine:
method → email → otpEach step is a separate UI component orchestrated by AuthForm:
| Step | Component | What Happens |
|---|---|---|
method | MethodSelection | User picks email, passkey, or an enabled social provider |
email | EmailInput | User enters email, OTP is sent |
otp | OtpVerification | User enters 6-digit code to complete sign-in |
State Machine
The state transitions are defined in apps/app/components/auth/use-auth-form.ts:
const VALID_TRANSITIONS: Record<AuthStep, AuthStep[]> = {
method: ["email"],
email: ["method", "otp"],
otp: ["email"],
};Invalid step jumps are ignored, keeping navigation within the declared state machine. Passkey completes outside that sequence, so a separate success guard runs the post-auth work once per form – disabling is state-backed, and two completions can land before React rerenders. Google leaves the page entirely and resolves on return.
Sending the OTP
When the user submits their email, the sendOtp function normalizes the input and calls the Better Auth client:
// "sign-in" type handles both login and signup
const result = await auth.emailOtp.sendVerificationOtp({
email: normalizedEmail,
type: "sign-in",
});The sign-in type is used for both login and signup flows. Better Auth creates the user account if the email is new.
Verifying the Code
The OtpVerification component handles code entry and verification:
const result = await auth.signIn.emailOtp({ email, otp });The input field restricts to 6 numeric digits with inputMode="numeric" and autoComplete="one-time-code" for mobile OTP autofill.
Error Handling
The OTP plugin returns specific error codes that map to user-friendly messages:
| Error Code | User Message | Behavior |
|---|---|---|
TOO_MANY_ATTEMPTS | "Too many failed attempts. Please request a new code." | Returns to email step |
OTP_EXPIRED | "Code has expired. Please request a new one." | Returns to email step |
INVALID_OTP | Server message or "Invalid verification code" fallback | Stays on OTP step (can retry) |
When TOO_MANY_ATTEMPTS or OTP_EXPIRED occurs, the form automatically returns to the email step so the user can request a fresh code.
Resend Cooldown
After the initial OTP is sent, users can request a new code with a 30-second cooldown:
const RESEND_COOLDOWN_SECONDS = 30;The resend button shows a countdown timer and is disabled during the cooldown period.
Component Architecture
AuthForm
├── MethodSelection Step 1: choose sign-in method
│ ├── GoogleLogin OAuth redirect (when configured)
│ ├── "Continue with email" button
│ └── PasskeyLogin WebAuthn (login only)
├── EmailInput Step 2: enter email, send OTP
└── OtpStep Step 3: wraps OTP UI with back link (internal to AuthForm)
└── OtpVerification Code entry and verificationThe AuthForm accepts a mode prop ("login" or "signup"). Both modes run the same OTP flow; the difference is headings, the account-switch link, and passkey availability – see the auth form spec.
INFO
Passkeys are only shown during login. They require an existing account with a registered passkey – see Passkeys.