A comprehensive One-Time Password (OTP) authentication plugin for Payload CMS that enables secure passwordless authentication via SMS and email.
- 🔐 Passwordless Authentication: Secure login using OTP codes
- 📱 Multi-Channel Support: SMS and email OTP delivery
- ⚡ Easy Integration: Simple plugin configuration
- 🎯 Flexible Hooks: Customizable
afterSetOtp
hook for integrations - 🔧 TypeScript Support: Full TypeScript support with proper type definitions
- 🛡️ Security: Automatic OTP expiration and cleanup
- 🏗️ Payload 3.x Compatible: Built for the latest Payload CMS
npm install payloadcms_otp_plugin
# or
yarn add payloadcms_otp_plugin
# or
pnpm add payloadcms_otp_plugin
Add the plugin to your Payload configuration:
import { buildConfig } from 'payload'
import { otpPlugin } from 'payloadcms_otp_plugin'
export default buildConfig({
// ... your existing config
plugins: [
otpPlugin({
collections: { users: true },
expiredTime: 300000, // 5 minutes
})
]
})
import { buildConfig } from 'payload'
import { otpPlugin } from 'payloadcms_otp_plugin'
import { sendSMS, sendEmail } from './your-services'
export default buildConfig({
plugins: [
otpPlugin({
collections: { users: true },
expiredTime: 300000, // 5 minutes in milliseconds
afterSetOtp: async ({ otp, credentials, otpRecord, payload, req }) => {
// Send OTP via SMS or Email
if (credentials.mobile) {
await sendSMS(credentials.mobile, `Your OTP code is: ${otp}`)
} else if (credentials.email) {
await sendEmail(credentials.email, 'Your OTP Code', `Your verification code is: ${otp}`)
}
// Optional: Log OTP creation for analytics
console.log(`OTP ${otp} created for ${credentials.mobile || credentials.email}`)
}
})
]
})
The plugin automatically adds these endpoints to your Payload API:
POST /api/otp/send
Content-Type: application/json
{
"mobile": "+1234567890" // or "email": "user@example.com"
}
Response:
{
"data": null,
"message": "OTP sent successfully",
"code": 200,
"error": false
}
POST /api/otp/login
Content-Type: application/json
{
"mobile": "+1234567890", // or "email": "user@example.com"
"otp": "123456"
}
Response:
{
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "user_id",
"email": "user@example.com",
"mobile": "+1234567890"
}
},
"message": "Login successful",
"code": 200,
"error": false
}
Option | Type | Default | Description |
---|---|---|---|
collections |
Partial<Record<CollectionSlug, true>> |
- | Collections to enhance with OTP functionality |
disabled |
boolean |
false |
Disable the plugin |
expiredTime |
number |
300000 |
OTP expiration time in milliseconds (5 minutes) |
otpLength |
number |
6 |
Length of the generated OTP code (4-12 digits) |
afterSetOtp |
AfterSetOtpHook |
undefined |
Hook executed after OTP creation |
otpPlugin({
collections: { users: true },
expiredTime: 10 * 60 * 1000, // 10 minutes
otpLength: 8, // 8-digit OTP
afterSetOtp: async ({ otp, credentials, otpRecord, payload, req }) => {
// Your custom logic here
}
})
The afterSetOtp
hook provides access to:
type AfterSetOtpHook = (args: {
otp: string; // Generated OTP code
credentials: { mobile?: string; email?: string }; // User credentials
otpRecord: any; // Database OTP record
payload: any; // Payload CMS instance
req: any; // Request object
}) => Promise<void> | void;
The plugin includes middleware for automatic OTP flow redirection in Next.js applications.
Create a middleware.ts
file in your project root:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { middlewareOtp } from 'payloadcms_otp_plugin/middleware'
export async function middleware(request: NextRequest): Promise<NextResponse> {
// Apply OTP middleware for admin routes
if (request.nextUrl.pathname.startsWith('/admin')) {
return await middlewareOtp(request)
}
return NextResponse.next()
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}
The middleware automatically:
-
Intercepts admin login requests (
/admin/login
) -
Redirects to OTP validation by modifying the
redirect
parameter -
Preserves original redirect intent using
admin-redirect
parameter - Handles nested redirects gracefully
Example 1: Direct admin access
User visits: /admin/login
Middleware redirects to: /admin/login?redirect=/otp-validation
After OTP: User goes to /admin (dashboard)
Example 2: Deep link access
User visits: /admin/login?redirect=/admin/posts
Middleware redirects to: /admin/login?redirect=/otp-validation&admin-redirect=/admin/posts
After OTP: User goes to /admin/posts
Example 3: Custom redirect preservation
User visits: /admin/login?redirect=/custom-page
Middleware redirects to: /admin/login?redirect=/otp-validation&admin-redirect=/custom-page
After OTP: User goes to /custom-page
You can extend the middleware for custom logic:
import { NextRequest, NextResponse } from 'next/server'
import { middlewareOtp } from 'payloadcms_otp_plugin/middleware'
export async function middleware(request: NextRequest): Promise<NextResponse> {
const url = request.nextUrl
// Custom logic before OTP middleware
if (url.pathname === '/admin/special-page') {
// Add custom headers or logic
const response = await middlewareOtp(request)
response.headers.set('X-Custom-Header', 'value')
return response
}
// Apply OTP middleware for all admin routes
if (url.pathname.startsWith('/admin')) {
return await middlewareOtp(request)
}
return NextResponse.next()
}
The middlewareOtp
function accepts optional configuration:
import { middlewareOtp } from 'payloadcms_otp_plugin/middleware'
// Basic usage
await middlewareOtp(request)
// With custom OTP validation path
await middlewareOtp(request, {
otpValidationPath: '/custom-otp-page'
})
The plugin provides ready-to-use React components for OTP functionality.
A complete OTP validation page with automatic configuration fetching.
import { OtpPage } from 'payloadcms_otp_plugin'
// app/otp-validation/page.tsx
export default function OTPValidationPage() {
return (
<div className="otp-container">
<h1>Enter Verification Code</h1>
<OtpPage
className="my-otp-form"
// Optional: override auto-fetched timer
// initialTimer={300} // 5 minutes in seconds
/>
</div>
)
}
Props:
-
className?: string
- CSS class for styling -
initialTimer?: number
- Fallback timer in seconds (auto-fetched from config)
Features:
- ✅ Server-side configuration fetching
- ✅ Automatic OTP length detection
- ✅ Configurable expiration timer
- ✅ Built-in loading states
- ✅ Error handling
- ✅ Responsive design
A flexible client-side OTP input component with full control.
'use client'
import { OtpView } from 'payloadcms_otp_plugin'
export default function CustomOTPPage() {
return (
<div>
<OtpView
otpLength={6} // Custom OTP length
expiredTime={300} // 5 minutes in seconds
className="custom-otp"
// Optional fallback timer
initialTimer={120} // 2 minutes fallback
/>
</div>
)
}
Props:
-
className?: string
- CSS class for styling -
otpLength?: number
- Override OTP length (fetched from config if not provided) -
expiredTime?: number
- Override timer in seconds (fetched from config if not provided) -
initialTimer?: number
- Fallback timer in seconds
Features:
- ✅ Dynamic configuration fetching
- ✅ Manual prop override support
- ✅ Real-time input validation
- ✅ Auto-focus management
- ✅ Timer countdown with resend
- ✅ Internationalization support
- ✅ Accessibility features
A standalone OTP input component for custom implementations.
'use client'
import { OTPInput } from 'payloadcms_otp_plugin'
import { useState } from 'react'
export default function CustomOTPInput() {
const [otp, setOtp] = useState('')
const [isDisabled, setIsDisabled] = useState(false)
const handleComplete = (code: string) => {
console.log('OTP entered:', code)
// Handle OTP submission
}
const handleReset = () => {
setOtp('')
setIsDisabled(false)
}
return (
<OTPInput
length={6} // Number of input fields
disabled={isDisabled} // Disable all inputs
value={otp} // Controlled value
onChange={setOtp} // Value change handler
onComplete={handleComplete} // Called when all fields filled
onReset={handleReset} // Reset trigger
className="my-otp-input"
/>
)
}
Props:
-
length?: number
- Number of OTP digits (default: 6) -
disabled?: boolean
- Disable input fields -
value?: string
- Controlled input value -
onChange?: (otp: string) => void
- Input change handler -
onComplete?: (otp: string) => void
- Called when OTP is complete -
onReset?: () => void
- Reset trigger function -
className?: string
- CSS class for styling
Features:
- ✅ Numeric input validation
- ✅ Auto-focus next/previous field
- ✅ Paste support
- ✅ Keyboard navigation (arrows, backspace)
- ✅ Accessibility labels
- ✅ Mobile-optimized input mode
Add custom CSS to style the OTP components:
/* Custom OTP styling */
.my-otp-form {
max-width: 400px;
margin: 0 auto;
padding: 2rem;
}
.otp-input__container {
display: flex;
gap: 0.5rem;
justify-content: center;
margin: 1rem 0;
}
.otp-input__field {
width: 3rem;
height: 3rem;
text-align: center;
font-size: 1.5rem;
border: 2px solid #e1e5e9;
border-radius: 0.5rem;
transition: border-color 0.2s;
}
.otp-input__field:focus {
outline: none;
border-color: #0070f3;
box-shadow: 0 0 0 3px rgba(0, 112, 243, 0.1);
}
.otp-input__field--disabled {
background-color: #f5f5f5;
color: #999;
}
.otp__loading {
text-align: center;
padding: 2rem;
color: #666;
}
.otp__timer-section {
text-align: center;
margin-top: 1rem;
}
Example 1: Custom OTP Page with Branding
import { OtpView } from 'payloadcms_otp_plugin'
export default function BrandedOTPPage() {
return (
<div className="auth-container">
<div className="brand-header">
<img src="/logo.png" alt="Company Logo" />
<h1>Secure Login</h1>
<p>Enter the verification code sent to your device</p>
</div>
<OtpView className="branded-otp" />
<div className="help-section">
<p>Didn't receive the code?</p>
<button>Contact Support</button>
</div>
</div>
)
}
Example 2: Multi-step Authentication Flow
'use client'
import { useState } from 'react'
import { OTPInput } from 'payloadcms_otp_plugin'
export default function MultiStepAuth() {
const [step, setStep] = useState(1) // 1: phone, 2: otp, 3: success
const [phone, setPhone] = useState('')
const [otp, setOtp] = useState('')
const sendOTP = async () => {
const response = await fetch('/api/otp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile: phone })
})
if (response.ok) setStep(2)
}
const verifyOTP = async (code: string) => {
const response = await fetch('/api/otp/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile: phone, otp: code })
})
const data = await response.json()
if (data.success) setStep(3)
}
return (
<div className="auth-flow">
{step === 1 && (
<div>
<h2>Enter Phone Number</h2>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 (555) 123-4567"
/>
<button onClick={sendOTP}>Send Code</button>
</div>
)}
{step === 2 && (
<div>
<h2>Enter Verification Code</h2>
<p>Sent to {phone}</p>
<OTPInput
length={6}
onComplete={verifyOTP}
className="auth-otp"
/>
</div>
)}
{step === 3 && (
<div>
<h2>✅ Authentication Successful</h2>
<p>Redirecting...</p>
</div>
)}
</div>
)
}
import twilio from 'twilio'
const client = twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN)
const sendSMS = async (mobile: string, message: string) => {
await client.messages.create({
body: message,
from: process.env.TWILIO_PHONE,
to: mobile
})
}
// In your plugin configuration
afterSetOtp: async ({ otp, credentials }) => {
if (credentials.mobile) {
await sendSMS(credentials.mobile, `Your verification code: ${otp}`)
}
}
import sgMail from '@sendgrid/mail'
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
const sendEmail = async (email: string, otp: string) => {
await sgMail.send({
to: email,
from: process.env.FROM_EMAIL,
subject: 'Your Verification Code',
html: `<p>Your verification code is: <strong>${otp}</strong></p>`
})
}
// In your plugin configuration
afterSetOtp: async ({ otp, credentials }) => {
if (credentials.email) {
await sendEmail(credentials.email, otp)
}
}
import { useState } from 'react'
const OTPLogin = () => {
const [phone, setPhone] = useState('')
const [otp, setOtp] = useState('')
const [otpSent, setOtpSent] = useState(false)
const sendOTP = async () => {
const response = await fetch('/api/otp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile: phone })
})
if (response.ok) {
setOtpSent(true)
}
}
const login = async () => {
const response = await fetch('/api/otp/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mobile: phone, otp })
})
const data = await response.json()
if (data.data?.token) {
localStorage.setItem('token', data.data.token)
// Redirect to authenticated area
}
}
return (
<div>
{!otpSent ? (
<div>
<input
type="tel"
placeholder="Phone number"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
<button onClick={sendOTP}>Send OTP</button>
</div>
) : (
<div>
<input
type="text"
placeholder="Enter OTP"
value={otp}
onChange={(e) => setOtp(e.target.value)}
/>
<button onClick={login}>Login</button>
</div>
)}
</div>
)
}
- Automatic Expiration: OTPs expire after the configured time
- One-Time Use: OTPs are marked as verified after successful use
- Cleanup: Expired OTPs are automatically removed
- Access Control: Proper authentication bypass for OTP endpoints
- JWT Integration: Secure token generation with session management
1. OTP Length Configuration
// For high-security applications (banking, finance)
otpLength: 8,
expiredTime: 2 * 60 * 1000, // 2 minutes
// For standard applications (e-commerce, social)
otpLength: 6,
expiredTime: 5 * 60 * 1000, // 5 minutes
// For development/testing
otpLength: 4,
expiredTime: 10 * 60 * 1000, // 10 minutes
2. Environment-based Configuration
otpPlugin({
otpLength: process.env.NODE_ENV === 'production' ? 6 : 4,
expiredTime: process.env.NODE_ENV === 'production'
? 3 * 60 * 1000 // 3 minutes in production
: 15 * 60 * 1000, // 15 minutes in development
afterSetOtp: async ({ otp, credentials }) => {
if (process.env.NODE_ENV === 'development') {
console.log(`🔐 DEV OTP: ${otp} for ${credentials.email || credentials.mobile}`)
} else {
// Send via production SMS/Email service
await sendProductionOTP(credentials, otp)
}
}
})
1. Rate Limiting
// Implement rate limiting in your afterSetOtp hook
const rateLimiter = new Map()
afterSetOtp: async ({ credentials, req }) => {
const identifier = credentials.mobile || credentials.email
const now = Date.now()
const attempts = rateLimiter.get(identifier) || []
// Clean old attempts (older than 1 hour)
const recentAttempts = attempts.filter(time => now - time < 60 * 60 * 1000)
if (recentAttempts.length >= 5) {
throw new Error('Too many OTP requests. Please try again later.')
}
rateLimiter.set(identifier, [...recentAttempts, now])
// Send OTP...
}
2. Database Optimization
// Add indexes to your OTP collection for better performance
// In your Payload config:
collections: [
{
slug: 'otpCode',
fields: [
// ... existing fields
],
indexes: [
{
fields: { mobile: 1, expiresAt: 1 }
},
{
fields: { email: 1, expiresAt: 1 }
},
{
fields: { expiresAt: 1 }, // For cleanup queries
expireAfterSeconds: 0 // MongoDB TTL index
}
]
}
]
1. Graceful Error Handling
afterSetOtp: async ({ otp, credentials, payload }) => {
try {
if (credentials.mobile) {
await sendSMS(credentials.mobile, otp)
} else if (credentials.email) {
await sendEmail(credentials.email, otp)
}
} catch (error) {
// Log error but don't expose details to user
console.error('Failed to send OTP:', error)
// Store fallback method or retry logic
await payload.create({
collection: 'otpFailures',
data: {
identifier: credentials.mobile || credentials.email,
error: error.message,
retryAfter: new Date(Date.now() + 5 * 60 * 1000)
}
})
throw new Error('Failed to send verification code. Please try again.')
}
}
2. User-Friendly Component Configuration
// Custom error messages and loading states
<OtpView
className="user-friendly-otp"
// Override default messages via props if needed
/>
// Add custom CSS for better UX
.user-friendly-otp .otp__loading {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
1. Unit Testing Components
// __tests__/OTPInput.test.tsx
import { render, fireEvent, screen } from '@testing-library/react'
import { OTPInput } from 'payloadcms_otp_plugin'
describe('OTPInput', () => {
it('should handle OTP completion', () => {
const onComplete = jest.fn()
render(<OTPInput length={6} onComplete={onComplete} />)
// Simulate typing OTP
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input, index) => {
fireEvent.change(input, { target: { value: `${index + 1}` } })
})
expect(onComplete).toHaveBeenCalledWith('123456')
})
})
2. Integration Testing
// __tests__/otp-flow.test.ts
import { testApiHandler } from 'next-test-api-route-handler'
import sendOtpHandler from '../pages/api/otp/send'
describe('/api/otp/send', () => {
it('should send OTP successfully', async () => {
await testApiHandler({
handler: sendOtpHandler,
test: async ({ fetch }) => {
const res = await fetch({
method: 'POST',
body: JSON.stringify({ mobile: '+1234567890' }),
headers: { 'Content-Type': 'application/json' }
})
const data = await res.json()
expect(res.status).toBe(200)
expect(data.success).toBe(true)
}
})
})
})
1. OTP Success Rate Tracking
afterSetOtp: async ({ otp, credentials, payload }) => {
// Track OTP generation
await payload.create({
collection: 'otpAnalytics',
data: {
type: 'generated',
identifier: credentials.mobile || credentials.email,
method: credentials.mobile ? 'sms' : 'email',
timestamp: new Date()
}
})
// Send OTP...
}
// In your OTP verification endpoint, track success/failure
await payload.create({
collection: 'otpAnalytics',
data: {
type: isValid ? 'success' : 'failure',
identifier: credentials.mobile || credentials.email,
timestamp: new Date()
}
})
2. Performance Monitoring
// Add timing metrics
const startTime = Date.now()
afterSetOtp: async ({ otp, credentials }) => {
try {
await sendOTP(credentials, otp)
// Log success timing
console.log(`OTP sent in ${Date.now() - startTime}ms`)
} catch (error) {
// Log failure timing
console.error(`OTP failed after ${Date.now() - startTime}ms:`, error)
throw error
}
}
pnpm install
pnpm build
pnpm test
pnpm dev
Contributions are welcome! Please feel free to submit a Pull Request.
MIT
Muhammad Fahmi Hidayah
Email: m.fahmi.hidayah@gmail.com
If you encounter any issues or have questions, please:
- Check the documentation
- Search existing issues
- Create a new issue
Built with ❤️ for the Payload CMS community