OTP Plugin

Email-based one-time password authentication for passwordless sign-in and verification flows.

This guide is part of the Drizzle stack docs.

Send 6-digit verification codes via email for passwordless sign-in, email verification, and password reset. The OTP plugin provides a code-based alternative to magic links.

This page is part of the Authentication documentation.

Overview

The OTP (One-Time Password) plugin sends 6-digit codes to users' email addresses. Unlike magic links that require clicking a URL, OTP codes can be entered directly in the app. This works better for:

  • Mobile users who may not want to switch apps
  • Environments where email links are blocked
  • Users who prefer typing a code over clicking links

Features

  • Passwordless sign-in - Sign in with just an email and code
  • Email verification - Verify email addresses with codes instead of links
  • Password reset - Reset passwords with email code verification

Configuration

The OTP plugin is enabled by default and requires no additional environment variables. It uses your existing mailer configuration.

Email Setup

Ensure your mailer is configured:

apps/web/.env.local

MAILER_PROVIDER=nodemailer
EMAIL_SENDER=noreply@yourapp.com
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USER=your_user
EMAIL_PASSWORD=your_password

See the Email Configuration documentation for full mailer setup.

Implementation

The OTP plugin is configured in packages/better-auth/src/plugins/otp-auth.ts:

import { emailOTP } from 'better-auth/plugins/email-otp';
import { getProductName } from '../utils/product-name';
export const otpPlugin = emailOTP({
async sendVerificationOTP({ email, otp, type }) {
// Log in development mode
if (process.env.NODE_ENV === 'development') {
console.log(`[DEV] OTP for ${email} (${type}): ${otp}`);
}
const productName = getProductName();
if (type === 'sign-in') {
const { sendOtpSignInEmail } =
await import('../emails/send-otp-sign-in-email');
await sendOtpSignInEmail({ email, otp, productName });
} else if (type === 'email-verification') {
const { sendOtpEmailVerificationEmail } =
await import('../emails/send-otp-email-verification-email');
await sendOtpEmailVerificationEmail({ email, otp, productName });
} else {
const { sendOtpPasswordResetEmail } =
await import('../emails/send-otp-password-reset-email');
await sendOtpPasswordResetEmail({ email, otp, productName });
}
},
});

OTP Types

TypePurposeEmail Template
sign-inPasswordless authenticationotp-sign-in.email.tsx
email-verificationVerify email addressotp-email-verification.email.tsx
forget-passwordPassword resetotp-password-reset.email.tsx

OTP Lifecycle

  1. User requests an OTP (sign-in, verification, or password reset)
  2. Server generates a 6-digit code
  3. Code is stored with expiration (typically 10 minutes)
  4. Email is sent to user with the code
  5. User enters code in the application
  6. Server validates code and completes action
  7. Code is invalidated after successful use

Development Mode

In development (NODE_ENV=development), OTP codes are logged to the console:

[DEV] OTP for user@example.com (sign-in): 847293

This allows testing without configuring email or checking inboxes.

Client Usage

Request OTP for Sign-In

import { authClient } from '@kit/better-auth/client';
// Request OTP code
await authClient.emailOtp.sendVerificationOtp({
email: 'user@example.com',
type: 'sign-in',
});
// User receives email with 6-digit code

Verify OTP and Sign In

import { authClient } from '@kit/better-auth/client';
const result = await authClient.signIn.emailOtp({
email: 'user@example.com',
otp: '847293',
});
if (result.error) {
console.error('Invalid or expired code');
}

Email Verification with OTP

import { authClient } from '@kit/better-auth/client';
// Request verification code
await authClient.emailOtp.sendVerificationOtp({
email: 'user@example.com',
type: 'email-verification',
});
// Verify the code
await authClient.emailOtp.verifyOtp({
email: 'user@example.com',
otp: '123456',
});

Password Reset with OTP

import { authClient } from '@kit/better-auth/client';
// Request password reset code
await authClient.emailOtp.sendVerificationOtp({
email: 'user@example.com',
type: 'forget-password',
});
// Verify code and set new password
await authClient.emailOtp.resetPassword({
email: 'user@example.com',
otp: '123456',
newPassword: 'newSecurePassword123',
});

Email Templates

OTP emails use templates in packages/email-templates/src/emails/:

  • otp-sign-in.email.tsx - Sign-in OTP
  • otp-email-verification.email.tsx - Email verification OTP
  • otp-password-reset.email.tsx - Password reset OTP

Each template receives:

  • email - Recipient email address
  • otp - The 6-digit code
  • productName - Your app name
FeatureOTPMagic Link
User actionType 6-digit codeClick link
Mobile UXBetter (stays in app)Requires app switch
Email blockingWorks if email deliversLinks may be blocked
ExpirationTypically shorterTypically longer
SecurityBoth are secureBoth are secure

Choose based on your users' preferences and environment.

Common Pitfalls

  • OTP expiration: Codes expire quickly (default 10 minutes). Users must enter codes promptly.
  • Email delays: Slow email delivery can cause codes to expire before arrival. Monitor delivery times.
  • Rate limiting: Multiple OTP requests may be rate limited. Implement appropriate UI feedback.
  • Code reuse: Each code can only be used once. Failed attempts don't consume the code.

Frequently Asked Questions

How long are OTP codes valid?
By default, OTP codes expire in 10 minutes. This can be configured in the plugin options.
Can I use OTP and magic links together?
Yes. Both plugins can be enabled simultaneously. Users can choose their preferred method.
What happens if a user requests multiple OTPs?
Each new OTP request invalidates the previous code. Only the most recent code is valid.
Are OTP codes case-sensitive?
No. OTP codes are numeric only (6 digits), so case sensitivity doesn't apply.
How do I customize OTP email templates?
Edit the email templates in packages/email-templates/src/emails/. Templates are React components using react-email.

Next: Rate Limiting →