Skip to content

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:

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:

ts
// 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 → otp

Each step is a separate UI component orchestrated by AuthForm:

StepComponentWhat Happens
methodMethodSelectionUser picks email, passkey, or an enabled social provider
emailEmailInputUser enters email, OTP is sent
otpOtpVerificationUser enters 6-digit code to complete sign-in

State Machine

The state transitions are defined in apps/app/components/auth/use-auth-form.ts:

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:

ts
// "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:

ts
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 CodeUser MessageBehavior
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_OTPServer message or "Invalid verification code" fallbackStays 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:

ts
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 verification

The 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.