Email OTP in Better Auth, end to end
Set up the Better Auth email OTP plugin, choose a sender for the codes, and handle expiry, tries and rate limits in production.
Better Auth’s email OTP plugin handles the hard parts of a 6-digit email code: generating it, storing it, expiring it and checking it. It leaves one thing to you, which is sending the email. This post sets the plugin up and covers the choices that matter in production.
Server setup
// auth.ts
import { betterAuth } from "better-auth";
import { emailOTP } from "better-auth/plugins";
export const auth = betterAuth({
// your database adapter, as usual
plugins: [
emailOTP({
otpLength: 6,
expiresIn: 600, // seconds
maxAttempts: 5,
async sendVerificationOTP({ email, otp, type }) {
// type: "sign-in" | "email-verification" | "forget-password" | "change-email"
await sendEmail({
to: email,
subject: `Your Kirana Express code is ${otp}`,
text: `Enter ${otp} to continue. It expires in 10 minutes.`,
});
},
}),
],
});
Client setup
import { createAuthClient } from "better-auth/client";
import { emailOTPClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({ plugins: [emailOTPClient()] });
// 1. send the code
await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
// 2. sign in with the code the user typed
await authClient.signIn.emailOtp({ email, otp });
Choosing what sends the email
sendEmail above is yours. Three things decide whether codes arrive:
- A verified sending domain. Send from
noreply@yourapp.inwith SPF, DKIM and DMARC set up. Codes from a Gmail address or an unverified domain land in spam. - A transactional provider, not a mailbox. Amazon SES, ZeptoMail, Resend or Postmark. Zoho Mail or Gmail SMTP have hourly limits and policies against bulk sending.
- Plain, short emails. The code in the subject line, no images, no marketing. Inbox providers treat code emails with links and images with more suspicion.
Production checklist
- Rate limit the send endpoint per email and per IP, and put a check such as Cloudflare Turnstile on the form. Otherwise anyone can use your app to email strangers.
- A resend wait of 30 seconds or so in your UI.
- Expiry and tries: 10 minutes and 5 tries are common; Better Auth’s defaults at the time of writing are 300 seconds and 3 tries.
- Watch bounces and complaints. A spike of either gets your sending domain throttled.
Or let an API send and check the code
If you would rather not store codes or manage email at all, SignOps Email OTP makes the code, sends it from your domain or ours, and checks it in two calls. Use it in a custom Better Auth plugin or your own route, and create the session once SignOps says the code is valid.
Sources: Better Auth: email OTP plugin.