
Product
Announcing Socket Fix 2.0
Socket Fix 2.0 brings targeted CVE remediation, smarter upgrade planning, and broader ecosystem support to help developers get to zero alerts.
payloadcms_otp_plugin
Advanced tools
A comprehensive One-Time Password (OTP) authentication plugin for Payload CMS that enables secure passwordless authentication via SMS and email
A comprehensive One-Time Password (OTP) authentication plugin for Payload CMS that enables secure passwordless authentication via SMS and email.
afterSetOtp
hook for integrationsnpm 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:
/admin/login
)redirect
parameteradmin-redirect
parameterExample 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 stylinginitialTimer?: number
- Fallback timer in seconds (auto-fetched from config)Features:
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 stylingotpLength?: 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 secondsFeatures:
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 fieldsvalue?: string
- Controlled input valueonChange?: (otp: string) => void
- Input change handleronComplete?: (otp: string) => void
- Called when OTP is completeonReset?: () => void
- Reset trigger functionclassName?: string
- CSS class for stylingFeatures:
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>
)
}
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:
Built with ❤️ for the Payload CMS community
FAQs
A comprehensive One-Time Password (OTP) authentication plugin for Payload CMS that enables secure passwordless authentication via SMS and email
The npm package payloadcms_otp_plugin receives a total of 32 weekly downloads. As such, payloadcms_otp_plugin popularity was classified as not popular.
We found that payloadcms_otp_plugin demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Product
Socket Fix 2.0 brings targeted CVE remediation, smarter upgrade planning, and broader ecosystem support to help developers get to zero alerts.
Security News
Socket CEO Feross Aboukhadijeh joins Risky Business Weekly to unpack recent npm phishing attacks, their limited impact, and the risks if attackers get smarter.
Product
Socket’s new Tier 1 Reachability filters out up to 80% of irrelevant CVEs, so security teams can focus on the vulnerabilities that matter.