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-otpplus 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:
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
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.jsonThis drops the following into your codebase:
src/lib/auth/email-otp-plugin.ts— theemailOtpPlugin()factorysrc/lib/auth/use-resend-cooldown.ts— countdown state for resend buttonssrc/lib/auth/use-sign-in-continuation.ts— shared post-sign-in handlersrc/lib/auth/two-factor-methods.ts: local two-factor redirect metadata supportsrc/components/auth/otp-field.tsx— the shared code inputsrc/components/auth/email-otp/email-otp.tsx— the sign-in formsrc/components/auth/email-otp/email-otp-button.tsx— the toggle buttonsrc/components/auth/email-otp/verify-email-otp.tsx— code-based email verificationsrc/components/auth/email-otp/forgot-password-otp.tsxandreset-password-otp.tsx— code-based password resetsrc/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:
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:
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.
| Option | Replaces | Server option it needs |
|---|---|---|
signIn (default true) | adds /auth/email-otp | none |
emailVerification | <VerifyEmail /> | overrideDefaultEmailVerification: true |
passwordReset | <ForgotPassword /> and <ResetPassword /> | none |
changeEmail | the change-email card in account settings | changeEmail: { enabled: true } |
verifyCurrentEmail | adds a step to the change-email flow | changeEmail: { 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