BETTER-AUTH. UI
Plugins

Email OTP

Replace emailed links with one-time codes for sign-in, verification, password reset, and email changes.

The email-OTP plugin swaps emailed links for short codes the user types back into the app. Every flow is opt-in, so you can keep the link-based views you like and replace only the ones you don't.

It contributes:

  • An <EmailOtp /> sign-in view at /auth/email-otp plus a "Continue with Email Code" button
  • Code-based replacements for the verify-email, forgot-password, reset-password, and change-email surfaces
  • Mutation hooks for every email-OTP endpoint (useSendVerificationOtp, useSignInEmailOtp, useVerifyEmailOtp, useRequestPasswordResetOtp, useResetPasswordOtp, useRequestEmailChangeOtp, useChangeEmailOtp)

When emailAndPassword.enabled === false, <EmailOtp /> takes over /auth/sign-in as the primary passwordless surface.

Email-OTP sign-in replaces the password, it doesn't add a step after it. If you want "password, then an emailed code", that's the two-factor plugin with otpOptions. Better Auth does not apply 2FA to passwordless methods, so email-OTP sign-in bypasses a configured second factor.

Setup

Install the Better Auth plugin

Add the Email OTP plugin to your server config and wire sendVerificationOTP to your email provider. One callback serves all four flows — type tells you which one:

lib/auth.ts
import { betterAuth } from "better-auth"
import { emailOTP } from "better-auth/plugins"

export const auth = betterAuth({
  // ...
  plugins: [
    emailOTP({ 
      disableSignUp: true, 
      sendVerificationOTP: async ({ email, otp, type }) => { 
        // Send `otp` to `email`. `type` is "sign-in", "email-verification",
        // "forget-password", or "change-email".
      } 
    }) 
  ]
})

Install the matching client plugin

lib/auth-client.ts
import { createAuthClient } from "better-auth/react"
import { emailOTPClient } from "better-auth/client/plugins"

export const authClient = createAuthClient({
  plugins: [emailOTPClient()] 
})

Install the UI plugin

npx shadcn@latest add https://better-auth-ui.com/r/email-otp.json

This drops the following into your codebase:

  • src/lib/auth/email-otp-plugin.ts — the emailOtpPlugin() factory
  • src/lib/auth/use-resend-cooldown.ts — countdown state for resend buttons
  • src/lib/auth/use-sign-in-continuation.ts — shared post-sign-in handler
  • src/lib/auth/two-factor-methods.ts: local two-factor redirect metadata support
  • src/components/auth/otp-field.tsx — the shared code input
  • src/components/auth/email-otp/email-otp.tsx — the sign-in form
  • src/components/auth/email-otp/email-otp-button.tsx — the toggle button
  • src/components/auth/email-otp/verify-email-otp.tsx — code-based email verification
  • src/components/auth/email-otp/forgot-password-otp.tsx and reset-password-otp.tsx — code-based password reset
  • src/components/auth/email-otp/change-email-otp.tsx — the change-email card
  • Provider-button and last-used badge components needed by the passwordless sign-in view

The registry also refreshes <AccountSettings />, so enabling the change-email override does not require a separate registry command.

Register the plugin

Turn on the flows you want:

components/providers.tsx
import { emailOtpPlugin } from "@/lib/auth/email-otp-plugin"
import { AuthProvider } from "@/components/auth/auth-provider"

<AuthProvider
  authClient={authClient}
  navigate={navigate}
  plugins={[emailOtpPlugin()]} 
>
  {children}
</AuthProvider>

Allow the new view path

The plugin contributes an email-otp segment to viewPaths.auth. Spread emailOtpPlugin().viewPaths?.auth into your auth route's allowed-paths set:

routes/auth/$path.tsx
import { viewPaths } from "@better-auth-ui/core"
import { createFileRoute, notFound } from "@tanstack/react-router"

import { Auth } from "@/components/auth/auth"
import { emailOtpPlugin } from "@/lib/auth/email-otp-plugin"

export const Route = createFileRoute("/auth/$path")({
  beforeLoad({ params: { path } }) {
    if (
      !Object.values({
        ...viewPaths.auth,
        ...emailOtpPlugin().viewPaths?.auth 
      }).includes(path)
    ) {
      throw notFound()
    }
  },
  component: AuthPage
})

function AuthPage() {
  const { path } = Route.useParams()
  return <Auth path={path} />
}

Choosing which flows use codes

Each option replaces one link-based surface. Turn a flow on in the UI only when the matching server option is set, otherwise the user waits for a code that never arrives.

OptionReplacesServer option it needs
signIn (default true)adds /auth/email-otpnone
emailVerification<VerifyEmail />overrideDefaultEmailVerification: true
passwordReset<ForgotPassword /> and <ResetPassword />none
changeEmailthe change-email card in account settingschangeEmail: { enabled: true }
verifyCurrentEmailadds a step to the change-email flowchangeEmail: { verifyCurrentEmail: true }
emailOtpPlugin({
  // Keep the emailed sign-in link, use codes for everything else.
  signIn: false,
  emailVerification: true,
  passwordReset: true,
  changeEmail: true
})

Confirming the current address

verifyCurrentEmail adds a step in front of the change: the user first types a code sent to their current address, and only then does the new address get one. It guards against a hijacked session being used to move the account — and with it the password-reset path — to an attacker's inbox.

The card handles the extra round trip for you; set the option on both sides or neither.

Sign-up and account creation

Better Auth creates an account for any address that completes an email-OTP sign-in, unless you set disableSignUp: true on the server. The UI mirrors that with disableSignUp defaulting to true.

The reason is account enumeration: collecting a name only for addresses that don't exist yet would tell an attacker which addresses are registered. Keep sign-up on the password or magic-link path, or build a combined flow that asks every user for the same fields.

Components

<EmailOtp />

A two-state form on one route — enter an email, then enter the code:

import { EmailOtp } from "@/components/auth/email-otp/email-otp"

<EmailOtp />

Prop

Type

<VerifyEmailOtp />

Rendered at /auth/verify-email when emailVerification is on. Reads the pending address from session storage (sign-up and sign-in put it there) and asks for it when it's missing.

Prop

Type

<ForgotPasswordOtp /> and <ResetPasswordOtp />

With passwordReset on, /auth/forgot-password emails a code and sends the user straight to /auth/reset-password, which takes the code and the new password together. The reset-link-sent view is skipped.

Prop

Type

<ChangeEmailOtp />

With changeEmail on, this replaces the built-in change-email card inside <AccountSettings /> — no extra wiring needed.

Prop

Type

Options

Prop

Type

Localization

Prop

Type

Read these from useAuthPlugin(emailOtpPlugin).localization inside custom slot components.

Email template

Pair the plugin with the <OtpEmail /> component to send a styled code from your sendVerificationOTP callback.

Last updated on

On this page