Recently I was working on a client project based on Payload CMS. The client wanted 2FA in the Payload login screen. I looked around for a bit and could not find a reliable implementation of it so I tried my own hand at it.
Here is the approach I came up with. If you want to go through the codebase directly then here is the link.
Overall Plan
Payload has a robust JWT implementation and I did not have enough time to replace this with a custom auth logic that will implement the same level of security measures.
So I wanted to add an OTP input requirement in the login screen but use Payload's default login logic.
Steps
Create a OTP
collection
Ideally you should store OTP codes in a Redis database, but for the sake of simplicity we are storing it in our Payload MongoDB database.
1// collections/OTP.ts23import type { CollectionConfig } from 'payload'45export const OTP: CollectionConfig = {6 slug: 'otp',7 admin: {8 hidden: true,9 },10 // This should be only accessable by Paylod local API. No other API should be able to access this collection.11 access: {12 create: () => false,13 read: () => false,14 update: () => false,15 delete: () => false,16 },17 fields: [18 {19 name: 'userEmail',20 type: 'email',21 required: true,22 index: true,23 },24 {25 name: 'otpCode',26 type: 'text',27 required: true,28 },29 {30 name: 'expiresAt',31 type: 'date',32 required: true,33 },34 {35 name: 'isUsed',36 type: 'checkbox',37 defaultValue: false,38 },39 ],40}41
After creating this you will need to include it in the collections
property of your Payload config. Then you will need to run the generate:types
script from package.json
Additionally, in MongoDB you will need to create an index for the expiresAt
property of the OTP
collection, and make sure its a TTL
index with appropriate expiration time. You can do this via MongoDB Compass or MongoDB Atlas. You can find additional information here. The TTL index will delete the document after the specified time from creation expires.
Create a Login UI
The next step is to create our custom login view. I re-created Payload's default login screen and added an additional OTP input.
- First we need to create a function to hash our OTP codes.
1// lib/hash23import 'server-only'45import { createHmac } from 'crypto'67export function hashWithHmac(value: string): string {8 const secret = process.env.PAYLOAD_SECRET910 if (!secret) {11 throw new Error('Failed to generate hash. Please check config')12 }1314 return createHmac('sha256', secret).update(value).digest('hex')15}16
- Create a server action to send the OTP code to the user's email
1// components/login/components/otp/send-otp.ts23'use server'45import { getPayload } from 'payload'6import config from '@payload-config'7import { hashWithHmac } from '@/app/(payload)/lib/hash'89export async function sendOtp(email: string): Promise<string | true> {10 const payload = await getPayload({ config })1112 if (!email) {13 return 'Email missing'14 }1516 // Check if user exists and their lock status17 const userResult = await payload.find({18 collection: 'users',19 where: {20 email: {21 equals: email,22 },23 },24 showHiddenFields: true,25 select: {26 lockUntil: true,27 },28 })2930 const user = userResult.docs[0]3132 if (!user) {33 return payload.config.i18n.translations.en?.error.noUser || 'User not found'34 }3536 if (user.lockUntil && new Date(user.lockUntil).getTime() > Date.now()) {37 return payload.config.i18n.translations.en?.error.userLocked || 'User is locked'38 }3940 /**41 * Check if we have already sent an OTP to this email42 *43 * The expiry time for an OTP is 2 minutes, we will allow the44 * user to request a new OTP after 1 minute.45 */46 const existingOtp = await payload.find({47 collection: 'otp',48 where: {49 userEmail: {50 equals: email,51 },52 isUsed: {53 equals: false,54 },55 expiresAt: {56 greater_than: new Date(Date.now() + 60000).toISOString(),57 },58 },59 sort: ['-createdAt'],60 pagination: false,61 limit: 1,62 })6364 if (existingOtp.docs[0]) {65 const expiryTime = existingOtp.docs[0].expiresAt66 const remainingTime = new Date(expiryTime).getTime() - (Date.now() + 60000)6768 return `An OTP has already been sent to this email. Please try again after ${Math.floor(remainingTime / 1000)} seconds.`69 }7071 // Generate a random 6 digit OTP72 const otp = Math.floor(100000 + Math.random() * 900000)7374 // Hash the OTP75 const hash = hashWithHmac(otp.toString())7677 // Save OTP in datbase78 let otpID: string = ''79 try {80 const result = await payload.create({81 collection: 'otp',82 data: {83 userEmail: email,84 otpCode: hash,85 expiresAt: new Date(Date.now() + 2 * 60000).toISOString(),86 },87 })8889 otpID = result.id90 } catch (e) {91 payload.logger.error(e, 'Failed to save OTP.')9293 return 'Failed to save OTP'94 }9596 // Send the OTP to the user97 try {98 // TODO: Remove this when the email service is setup99 payload.logger.info(`Sending OTP to ${email}. OTP: ${otp}`)100101 await payload.sendEmail({102 to: email,103 subject: 'Merton College | Login OTP',104 html: `105 <div style="font-family: Arial, sans-serif;">106 <p style="margin-bottom: 20px; font-size: 16px;">107 Please login using the OTP provided below. If you did not request this OTP, please ignore this email.108 </p>109110 <div style="margin-bottom: 20px; display: flex; flex-direction: column; gap: 8px; background-color: #f5f5f5; padding: 16px; border-radius: 6px; font-size: 16px; line-height: 1.5; ">111 <span><strong>OTP:</strong> ${otp}</span>112 </div>113114 <p style="margin-bottom: 20px; font-size: 16px;">115 <strong>116 This OTP is valid for 2 minutes.117 </strong>118 </p>119 </div>120 `,121 })122 } catch (e) {123 payload.logger.error(e, 'Failed to send OTP email.')124125 // Delete the OTP from the database126 await payload.delete({127 collection: 'otp',128 id: otpID,129 })130131 return 'Failed to send OTP'132 }133134 return true135}136
In this example I am printing the OTP code to the console. Once you have a SMTP setup you can remove this console log.
- Create the OTP input field.
1// components/login/components/otp/index.tsx23'use client'45import { Button, useField, toast, NumberField, useTranslation } from '@payloadcms/ui'6import { sendOtp } from './send-otp'7import { useState } from 'react'89export function OTPComponent() {10 const { value } = useField<string>({ path: 'email' })11 const { t } = useTranslation()1213 const [isSubmitting, setIsSubmitting] = useState(false)1415 return (16 <div17 style={{18 display: 'flex',19 gap: '1rem',20 }}21 className="login-otp-input-parent"22 >23 <NumberField24 field={{25 name: 'otp',26 label: 'OTP',27 required: true,28 }}29 path="otp"30 validate={(value) => {31 if (!value) {32 return t('validation:required')33 }3435 if (value.toString().length !== 6) {36 return 'OTP must be 6 digits'37 }3839 return true40 }}41 />4243 <Button44 type="button"45 size="large"46 buttonStyle="primary"47 disabled={isSubmitting}48 onClick={async () => {49 setIsSubmitting(true)5051 const result = await sendOtp(value)5253 if (result !== true) {54 toast.error(result)55 } else {56 toast.success('OTP sent successfully')57 }5859 setIsSubmitting(false)60 }}61 >62 Send OTP63 </Button>64 </div>65 )66}67
- Create the login form component
1// components/login/components/login-form.tsx23'use client'45import React from 'react'6import type { UserWithToken } from '@payloadcms/ui'7import type { FormState } from 'payload'8import {9 EmailField,10 Form,11 FormSubmit,12 PasswordField,13 useAuth,14 useConfig,15 useTranslation,16} from '@payloadcms/ui'17import { formatAdminURL } from '@payloadcms/ui/shared'18import { email } from 'payload/shared'19import Link from 'next/link'20import { OTPComponent } from './otp'2122const baseClass = 'login__form'2324export const LoginForm: React.FC<{25 prefillEmail?: string26 prefillPassword?: string27 searchParams: { [key: string]: string | string[] | undefined } | undefined28}> = ({ prefillEmail, prefillPassword, searchParams }) => {29 const { config } = useConfig()3031 const {32 admin: {33 routes: { forgot: forgotRoute },34 },35 routes: { admin: adminRoute },36 } = config3738 const { t } = useTranslation()39 const { setUser } = useAuth()4041 const initialState: FormState = {42 password: {43 initialValue: prefillPassword ?? undefined,44 valid: true,45 value: prefillPassword ?? undefined,46 },47 otp: {48 initialValue: undefined,49 valid: false,50 value: undefined,51 },52 }5354 initialState.email = {55 initialValue: prefillEmail ?? undefined,56 valid: true,57 value: prefillEmail ?? undefined,58 }5960 const handleLogin = (data: UserWithToken) => {61 setUser(data)62 }6364 return (65 <Form66 action={`/api/login`}67 className={baseClass}68 disableSuccessStatus69 initialState={initialState}70 method="POST"71 onSuccess={async (json) => {72 handleLogin(json as UserWithToken)73 }}74 redirect={typeof searchParams?.redirect === 'string' ? searchParams.redirect : adminRoute}75 waitForAutocomplete76 >77 <div className={`${baseClass}__inputWrap`}>78 <EmailField79 field={{80 name: 'email',81 admin: {82 autoComplete: 'email',83 placeholder: '',84 },85 label: t('general:email'),86 required: true,87 }}88 path="email"89 validate={email}90 />91 <PasswordField92 field={{93 name: 'password',94 label: t('general:password'),95 required: true,96 }}97 path="password"98 validate={(value) => {99 if (!value) {100 return t('validation:required')101 }102 return true103 }}104 />105 <OTPComponent />106 </div>107108 <Link109 href={formatAdminURL({110 adminRoute,111 path: forgotRoute,112 })}113 prefetch={false}114 >115 {t('authentication:forgotPasswordQuestion')}116 </Link>117118 <FormSubmit size="large">{t('authentication:login')}</FormSubmit>119 </Form>120 )121}122
- Create the login view component
1// components/login/index.tsx23import { redirect } from 'next/navigation.js'4import React from 'react'5import { LoginForm } from './components/login-form'6import { MinimalTemplate } from '@payloadcms/next/templates'7import type { AdminViewServerProps } from 'payload'8import { PayloadLogo } from '@payloadcms/ui/shared'910export const loginBaseClass = 'login'1112export const LoginView: React.FC<AdminViewServerProps> = ({ searchParams, initPageResult }) => {13 const { req } = initPageResult1415 const {16 payload: { config },17 user,18 } = req1920 const {21 routes: { admin },22 } = config2324 if (user) {25 if (!searchParams?.redirect) {26 redirect(admin)27 } else if (typeof searchParams?.redirect === 'string') {28 redirect(searchParams.redirect)29 } else {30 redirect(searchParams.redirect.join('/'))31 }32 }3334 const prefillAutoLogin =35 typeof config.admin?.autoLogin === 'object' && config.admin?.autoLogin.prefillOnly3637 const prefillEmail =38 prefillAutoLogin && typeof config.admin?.autoLogin === 'object'39 ? config.admin?.autoLogin.email40 : undefined4142 const prefillPassword =43 prefillAutoLogin && typeof config.admin?.autoLogin === 'object'44 ? config.admin?.autoLogin.password45 : undefined4647 return (48 <MinimalTemplate>49 <div className={`${loginBaseClass}__brand`}>50 <PayloadLogo />51 </div>52 <LoginForm53 prefillEmail={prefillEmail}54 prefillPassword={prefillPassword}55 searchParams={searchParams}56 />57 </MinimalTemplate>58 )59}60
Create Login Route Handler
We need to create a route handler to process our login request and validate the OTP code provided by the user.
Here is what this endpoint does in short:
- If the OTP is valid then the request is forwarded to Payload's default endpoint for user authentication.
- If not then I am using Payload's translation object to return appropriate error messages. This is to keep the experience consistent with Payload's default authentication endpoint as that also uses this object for error messages.
1// (payload)/api/login/route.ts23import { NextResponse } from 'next/server'4import { hashWithHmac } from '../../lib/hash'5import { getPayload, headersWithCors, type User } from 'payload'6import config from '@payload-config'78interface IPayloadLoginForm {9 email?: string10 otp?: number11 password?: string12}1314export async function POST(req: Request): Promise<Response> {15 const formData = await req.formData()1617 const form = formData.get('_payload') as string | null1819 if (!form) {20 return NextResponse.json(21 {22 errors: [{ message: 'Invalid form submission.' }],23 },24 { status: 401 },25 )26 }2728 const parsedFormData = JSON.parse(form) as IPayloadLoginForm2930 const { email, otp, password } = parsedFormData3132 // Validate form fields33 if (!email || !otp || !password) {34 return NextResponse.json(35 {36 errors: [{ message: 'Invalid form submission.' }],37 },38 { status: 401 },39 )40 }4142 const payload = await getPayload({ config })4344 const userResult = await payload.find({45 collection: 'users',46 where: {47 email: {48 equals: email,49 },50 },51 showHiddenFields: true,52 select: {53 loginAttempts: true,54 lockUntil: true,55 },56 })5758 const user = userResult.docs[0]5960 if (!user) {61 return NextResponse.json(62 {63 errors: [64 {65 message: payload.config.i18n.translations.en?.error.emailOrPasswordIncorrect,66 },67 ],68 },69 {70 status: 401,71 },72 )73 }7475 // Check if user is locked out76 if (user.lockUntil) {77 const lockUntil = new Date(user.lockUntil).getTime()7879 if (lockUntil > Date.now()) {80 return NextResponse.json(81 {82 errors: [83 {84 message: payload.config.i18n.translations.en?.error.userLocked,85 },86 ],87 },88 {89 status: 401,90 },91 )92 }93 }9495 // Process OTP code.96 const otpDocs = await payload.find({97 collection: 'otp',98 where: {99 userEmail: {100 equals: email,101 },102 otpCode: {103 equals: hashWithHmac(otp.toString()),104 },105 expiresAt: {106 greater_than: new Date().toISOString(),107 },108 isUsed: {109 equals: false,110 },111 },112 })113114 if (!otpDocs.docs[0]) {115 // Increase user login attempt count. Lock account if max attempts reached.116 const { maxLoginAttempts, lockTime } = payload.collections.users.config.auth117118 const updatePayload: Pick<User, 'loginAttempts' | 'lockUntil'> = {119 loginAttempts: Number(user.loginAttempts) + 1,120 lockUntil: null,121 }122123 if (updatePayload.loginAttempts >= maxLoginAttempts) {124 updatePayload.lockUntil = new Date(Date.now() + lockTime).toISOString()125 }126127 await payload.update({128 collection: 'users',129 id: user.id,130 data: updatePayload,131 })132133 return NextResponse.json(134 {135 errors: [136 {137 message: payload.config.i18n.translations.en?.error.emailOrPasswordIncorrect,138 },139 ],140 },141 {142 status: 401,143 },144 )145 }146147 /**148 * If OTP is good then process the rest of the login logic.149 * 1. Extract method, headers, and body from the request150 * 2. Clone headers and remove Content-Type and Content-Length headers151 * 3. Extract form data from the incoming request152 * 4. Call the users login route with headers and form data153 * 5. Extract headers and body from the response154 * 6. Set Content-Encoding to identity to prevent Next.js from trying to decompress the response155 * 7. Return the response156 */157 const { method, headers: originalHeaders } = req158159 const modifiedHeaders = new Headers(originalHeaders)160161 modifiedHeaders.delete('Content-Type')162 modifiedHeaders.delete('Content-Length')163164 const usersLoginResponse = await fetch(process.env.NEXT_PUBLIC_SERVER_URL + '/api/users/login', {165 method,166 headers: headersWithCors({167 headers: modifiedHeaders,168 req: {169 headers: modifiedHeaders,170 payload,171 },172 }),173 body: formData,174 })175176 // If login is successful and we have an OTP, mark the OTP as used.177 if (usersLoginResponse.ok && otpDocs?.docs[0]) {178 // Mark the OTP as used.179 await payload.update({180 collection: 'otp',181 id: otpDocs.docs[0].id,182 data: {183 isUsed: true,184 },185 })186 }187188 const usersLoginResponseHeaders = new Headers(usersLoginResponse.headers)189190 // Set Content-Encoding to identity to prevent Next.js from trying to decompress the response191 usersLoginResponseHeaders.set('Content-Encoding', 'identity')192193 const usersLoginResponseBody = await usersLoginResponse.text()194195 return new NextResponse(usersLoginResponseBody, {196 status: usersLoginResponse.status,197 headers: usersLoginResponseHeaders,198 })199}200
For this to work properly you will need to be able to have the domain URL in the environment. In this example I have set it to the NEXT_PUBLIC_SERVER_URL
environment variable.
Create a Payload English Translation Object
We are using Payload's default authentication endpoint which uses Payload's translation object. In the event the user gets the password wrong, they will see a and error toast stating their email and password do not match.
To ensure the error messages are ambiguous and no information is leaked. We have to overwrite the translation object with an appropriate error message. We will start by creating a file to contain a customized English translation object. The whole file is too big for me to put here, you can find the file here.
I will highlight the part I am changing.
1// (payload)/translations/en.ts23export const enTranslations = {4 //.....5 error: {6 //...7 emailOrPasswordIncorrect: 'The email, password or otp provided is incorrect.',8 },9 //....10}
Update Payload Config
Next you will need to update the Payload config file with the following changes:
- Overwrite the English translation object with our customized one.
1// src/payload.config.ts23import { enTranslations } from './app/(payload)/translations/en'45export default buildConfig({6 //....7 i18n: {8 translations: {9 en: enTranslations,10 },11 },12 //....13})
- Update the default login route as follows:
1// src/payload.config.ts23import { enTranslations } from './app/(payload)/translations/en'45export default buildConfig({6 //....7 admin: {8 // Moving default login page to deprecated route.9 routes: {10 login: '/deprecated-login',11 },12 //...13 },14 //....15})
- Register our custom Login component in the
/login
path
1// src/payload.config.ts23import { enTranslations } from './app/(payload)/translations/en'45export default buildConfig({6 //....7 admin: {8 components: {9 views: {10 customLogin: {11 Component: {12 path: '/app/(payload)/components/login#LoginView',13 },14 path: '/login',15 },16 },17 },18 //...19 },20 //....21})
Don't forget to run generate:importmap
after making these changes to the Payload config file.
Update Next Config
Now that we have updated the default login route to admin/deprecated-login
, this is where Payload will redirect us if we are logged out.
So we need to update the next.config
file with a permanent redirect, to redirect the user from admin/deprecated-login
to /login
1// next.config.mjs23import { withPayload } from '@payloadcms/next/withPayload'45/** @type {import('next').NextConfig} */6const nextConfig = {7 // ...8 redirects: async () => {9 return [10 // Redirect from payloads default login to our custom login path11 {12 source: '/admin/deprecated-login',13 destination: '/admin/login',14 permanent: true,15 },16 ]17 },18}1920export default withPayload(nextConfig)21
Issues
There are some issues I haven't quite worked out with this solution yet. If I do I will make sure to update this blog and my GitHub repository.
- I am not confident on my approach with error handling and overwriting the entire english translations object. This does not scale well when I have to support multiple languages.
- I was not able to test if CORS is working properly.