Connect six-digit verification codes, server policy, delivery, and the BTST verification page without confusing verification with passwordless sign-in.

BTST's email-verification page accepts a six-digit code. To use it after password registration, connect Better Auth's Email OTP plugin, its browser client, and the matching UI option. A verification-link email does not supply the code this form expects.
This guide extends a working integration with @btst/better-auth-ui@2.0.0 and Better Auth 1.6.16. The delivery example uses a Next.js App Router handler. Start with the auth integration guide if your server, browser client, and provider are not connected.
Email ownership verification and passwordless sign-in are separate jobs. The companion exposes emailVerification: { otp: true } for verification and emailOTP: true for the passwordless sign-in form. Enabling one does not mean you intended the other.
The released verification form reads the email from the page's query string, accepts six digits, and calls authClient.emailOtp.verifyEmail. Its resend action requests an OTP with type email-verification. Keep the server's code length at six when using this built-in form.
If you prefer verification links, retain your link delivery callback and omit the OTP UI option. Do not point a verification-link email at the six-digit form or rename a link token as an OTP.
Create these additions beside your existing auth configuration:
import type { BetterAuthOptions } from "better-auth";
import { emailOTP } from "better-auth/plugins/email-otp";
import { after } from "next/server";
import { sendEmail } from "@/lib/email";
export const verificationPolicy = {
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
autoSignInAfterVerification: false,
},
} satisfies Partial<BetterAuthOptions>;
export const verificationPlugin = emailOTP({
otpLength: 6,
expiresIn: 300,
allowedAttempts: 3,
storeOTP: "hashed",
disableSignUp: true,
overrideDefaultEmailVerification: true,
sendVerificationOTP: async ({ email, otp, type }) => {
after(async () => {
await sendEmail({
to: email,
subject: "Your account verification code",
text: `Your code for ${type} is ${otp}. It expires in five minutes.`,
});
});
},
});
Merge the two policy objects into their corresponding existing options and append verificationPlugin to your existing plugins array. Preserve password constraints, registration policy, recovery callbacks, database configuration, secret, and other plugins. Do not replace the whole auth configuration with this fragment.
In the pinned Email OTP implementation, overrideDefaultEmailVerification connects the core verification-mail callback to OTP delivery. This example uses core sendOnSignUp and sendOnSignIn triggers; it does not also enable the plugin's separate signup hook. Unverified password sign-in is rejected and can trigger another verification message.
disableSignUp here prevents automatic account creation through OTP sign-in. It does not disable password registration, remove the plugin's passwordless endpoints, or replace authorization. Hiding the passwordless UI is likewise not a server policy. Review all enabled auth methods against your product's account rules.
sendEmail must be a server-only integration that rejects provider errors. The Next.js after callback schedules delivery after the response within the request's supported lifetime. It is not a durable retry queue. For another framework, use its supported background work or your existing delivery queue. Do not log codes, email bodies, or private recipient data.
Append emailOTPClient() to the auth client already passed to BTST:
import { createAuthClient } from "better-auth/react";
import { emailOTPClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [emailOTPClient()],
});
Preserve your other client plugins, base URL, and API mount. Then merge this option into the existing overrides.auth object:
import type { AuthPluginOverrides } from "@btst/better-auth-ui/client";
export const verificationUI = {
emailVerification: { otp: true },
} satisfies Partial<AuthPluginOverrides>;
With the default view paths and a /p site mount, the form is /p/auth/email-verification?email=.... The released signup form navigates there when registration returns no session token and OTP verification is enabled. The sign-in form also navigates there on EMAIL_NOT_VERIFIED. Preserve the provider's navigation and session-refresh wiring.
With autoSignInAfterVerification: false, successful code verification returns the user to sign-in. Enabling automatic sign-in changes that transition and needs a corresponding session test. The page query string identifies the address to verify; the server still checks the code. Keep email parameters out of analytics and shared error reports.
| Check | Expected result with this configuration |
|---|---|
| Register a new test account | Account is unverified; a six-digit code is delivered |
| Sign in before verification | Server refuses password sign-in |
| Submit an incorrect or expired code | Server refuses verification |
| Submit a valid code for a different email | Server refuses verification for that address |
| Submit the correct code | Email becomes verified; user returns to sign-in |
| Reuse a consumed code | Server refuses another verification |
The form's 30-second resend countdown is a convenience, not abuse protection. Keep server rate limiting and attempt limits enabled, and test them through the API. Also check delivery failure handling and a real inbox before launch. The examples were type-checked and exercised with an in-memory auth handler; that does not verify your email provider or deployed browser flow.
Use the Email OTP documentation for the API and BTST auth documentation for the provider. For account recovery, continue with the separate password-reset guide.