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()]
})Register the UI plugin
Pass emailOtpPlugin() to <AuthProvider> and turn on the flows you want:
import { AuthProvider } from "@better-auth-ui/heroui"
import { emailOtpPlugin } from "@better-auth-ui/heroui/plugins"
<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 { Auth } from "@better-auth-ui/heroui"
import { emailOtpPlugin } from "@better-auth-ui/heroui/plugins"
import { createFileRoute, notFound } from "@tanstack/react-router"
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} />
}import { viewPaths } from "@better-auth-ui/core"
import { Auth } from "@better-auth-ui/heroui"
import { emailOtpPlugin } from "@better-auth-ui/heroui/plugins"
import { notFound } from "next/navigation"
export default async function AuthPage({
params
}: {
params: Promise<{ path: string }>
}) {
const { path } = await params
if (
!Object.values({
...viewPaths.auth,
...emailOtpPlugin().viewPaths?.auth
}).includes(path)
) {
notFound()
}
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
})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 "@better-auth-ui/heroui/plugins"
<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.
import { VerifyEmailOtp } from "@better-auth-ui/heroui/plugins"
<VerifyEmailOtp />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.
import {
ForgotPasswordOtp,
ResetPasswordOtp
} from "@better-auth-ui/heroui/plugins"Prop
Type
<ChangeEmailOtp />
With changeEmail on, this replaces the built-in change-email card inside <AccountSettings /> — no extra wiring needed. With verifyCurrentEmail it becomes a three-step flow: confirm the current address, then the new one.
import { ChangeEmailOtp } from "@better-auth-ui/heroui/plugins"
<ChangeEmailOtp />Prop
Type
Options
emailOtpPlugin({
// Override the URL segment. Default: "email-otp"
path: "code",
// Match the server's `otpLength`. Default: 6
otpLength: 6,
// Override any of the plugin's localization strings.
localization: {
sendCode: "Email me a code"
}
})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