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=nodemailerEMAIL_SENDER=noreply@yourapp.comEMAIL_HOST=smtp.example.comEMAIL_PORT=587EMAIL_USER=your_userEMAIL_PASSWORD=your_passwordSee 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
| Type | Purpose | Email Template |
|---|---|---|
sign-in | Passwordless authentication | otp-sign-in.email.tsx |
email-verification | Verify email address | otp-email-verification.email.tsx |
forget-password | Password reset | otp-password-reset.email.tsx |
OTP Lifecycle
- User requests an OTP (sign-in, verification, or password reset)
- Server generates a 6-digit code
- Code is stored with expiration (typically 10 minutes)
- Email is sent to user with the code
- User enters code in the application
- Server validates code and completes action
- 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): 847293This allows testing without configuring email or checking inboxes.
Client Usage
Request OTP for Sign-In
import { authClient } from '@kit/better-auth/client';// Request OTP codeawait authClient.emailOtp.sendVerificationOtp({ email: 'user@example.com', type: 'sign-in',});// User receives email with 6-digit codeVerify 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 codeawait authClient.emailOtp.sendVerificationOtp({ email: 'user@example.com', type: 'email-verification',});// Verify the codeawait authClient.emailOtp.verifyOtp({ email: 'user@example.com', otp: '123456',});Password Reset with OTP
import { authClient } from '@kit/better-auth/client';// Request password reset codeawait authClient.emailOtp.sendVerificationOtp({ email: 'user@example.com', type: 'forget-password',});// Verify code and set new passwordawait 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 OTPotp-email-verification.email.tsx- Email verification OTPotp-password-reset.email.tsx- Password reset OTP
Each template receives:
email- Recipient email addressotp- The 6-digit codeproductName- Your app name
OTP vs Magic Links
| Feature | OTP | Magic Link |
|---|---|---|
| User action | Type 6-digit code | Click link |
| Mobile UX | Better (stays in app) | Requires app switch |
| Email blocking | Works if email delivers | Links may be blocked |
| Expiration | Typically shorter | Typically longer |
| Security | Both are secure | Both 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?
Can I use OTP and magic links together?
What happens if a user requests multiple OTPs?
Are OTP codes case-sensitive?
How do I customize OTP email templates?
Next: Rate Limiting →