payloadcms_otp_plugin
TypeScript icon, indicating that this package has built-in type declarations

1.1.9 • Public • Published

Payload CMS OTP Authentication Plugin

A comprehensive One-Time Password (OTP) authentication plugin for Payload CMS that enables secure passwordless authentication via SMS and email.

Features

  • 🔐 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

Installation

npm install payloadcms_otp_plugin
# or
yarn add payloadcms_otp_plugin
# or
pnpm add payloadcms_otp_plugin

Quick Start

1. Basic Configuration

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
    })
  ]
})

2. Enhanced Configuration with Custom Hook

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}`)
      }
    })
  ]
})

API Endpoints

The plugin automatically adds these endpoints to your Payload API:

Send OTP

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
}

Login with OTP

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
}

Configuration Options

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

Advanced Configuration

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
  }
})

AfterSetOtp Hook

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;

Middleware Integration

The plugin includes middleware for automatic OTP flow redirection in Next.js applications.

Setting Up Middleware

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).*)',
  ],
}

How Middleware Works

The middleware automatically:

  1. Intercepts admin login requests (/admin/login)
  2. Redirects to OTP validation by modifying the redirect parameter
  3. Preserves original redirect intent using admin-redirect parameter
  4. Handles nested redirects gracefully

Middleware Flow Examples

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

Customizing Middleware

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()
}

Middleware Configuration Options

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'
})

UI Components

The plugin provides ready-to-use React components for OTP functionality.

OtpPage Component (Server Component)

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

OtpView Component (Client Component)

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

OTPInput Component

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

Styling Components

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;
}

Component Integration Examples

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>
  )
}

Integration Examples

SMS Integration with Twilio

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}`)
  }
}

Email Integration with SendGrid

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)
  }
}

Frontend Integration

React Example

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>
  )
}

Security Features

  • 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

Best Practices & Configuration Guide

Security Considerations

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)
    }
  }
})

Performance Optimization

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
      }
    ]
  }
]

Error Handling & User Experience

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; }
}

Testing Strategies

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)
      }
    })
  })
})

Monitoring & Analytics

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
  }
}

Development

Building the Plugin

pnpm install
pnpm build

Running Tests

pnpm test

Development Server

pnpm dev

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

MIT

Author

Muhammad Fahmi Hidayah
Email: m.fahmi.hidayah@gmail.com

Support

If you encounter any issues or have questions, please:

  1. Check the documentation
  2. Search existing issues
  3. Create a new issue

Built with ❤️ for the Payload CMS community

Package Sidebar

Install

npm i payloadcms_otp_plugin

Weekly Downloads

34

Version

1.1.9

License

MIT

Unpacked Size

192 kB

Total Files

81

Last publish

Collaborators

  • muhfahmih