Skip to content

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.

OTPUpdated 19 October 20266 min read

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:

  1. A verified sending domain. Send from noreply@yourapp.in with SPF, DKIM and DMARC set up. Codes from a Gmail address or an unverified domain land in spam.
  2. 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.
  3. 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.

Frequently asked questions

More questions? Email us and the answer goes on this page.

hello@signops.dev
Does Better Auth send the OTP email itself?

No. The email OTP plugin creates and checks the code, and calls your sendVerificationOTP function to deliver it. You choose the email provider.

What are the Better Auth email OTP defaults?

At the time of writing the docs list a 6-digit code, a 300-second expiry and 3 tries before the code is invalid. All three can be changed with otpLength, expiresIn and maxAttempts. Check the docs for your installed version.

Can I use Better Auth email OTP for sign-up and password reset?

Yes. The type passed to sendVerificationOTP tells you which flow it is (sign-in, email-verification, forget-password or change-email), so you can word each email differently.

Send your first push today.

Free for 1,000 push devices, 100 approvals a day, 10,000 passkey sign-ins a month and 500 email codes to try. No card until you pass a free amount.