Payload CMS: 2FA Implementation

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.ts
2
3import type { CollectionConfig } from 'payload'
4
5export 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.

  1. First we need to create a function to hash our OTP codes.
1// lib/hash
2
3import 'server-only'
4
5import { createHmac } from 'crypto'
6
7export function hashWithHmac(value: string): string {
8 const secret = process.env.PAYLOAD_SECRET
9
10 if (!secret) {
11 throw new Error('Failed to generate hash. Please check config')
12 }
13
14 return createHmac('sha256', secret).update(value).digest('hex')
15}
16
  1. Create a server action to send the OTP code to the user's email
1// components/login/components/otp/send-otp.ts
2
3'use server'
4
5import { getPayload } from 'payload'
6import config from '@payload-config'
7import { hashWithHmac } from '@/app/(payload)/lib/hash'
8
9export async function sendOtp(email: string): Promise<string | true> {
10 const payload = await getPayload({ config })
11
12 if (!email) {
13 return 'Email missing'
14 }
15
16 // Check if user exists and their lock status
17 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 })
29
30 const user = userResult.docs[0]
31
32 if (!user) {
33 return payload.config.i18n.translations.en?.error.noUser || 'User not found'
34 }
35
36 if (user.lockUntil && new Date(user.lockUntil).getTime() > Date.now()) {
37 return payload.config.i18n.translations.en?.error.userLocked || 'User is locked'
38 }
39
40 /**
41 * Check if we have already sent an OTP to this email
42 *
43 * The expiry time for an OTP is 2 minutes, we will allow the
44 * 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 })
63
64 if (existingOtp.docs[0]) {
65 const expiryTime = existingOtp.docs[0].expiresAt
66 const remainingTime = new Date(expiryTime).getTime() - (Date.now() + 60000)
67
68 return `An OTP has already been sent to this email. Please try again after ${Math.floor(remainingTime / 1000)} seconds.`
69 }
70
71 // Generate a random 6 digit OTP
72 const otp = Math.floor(100000 + Math.random() * 900000)
73
74 // Hash the OTP
75 const hash = hashWithHmac(otp.toString())
76
77 // Save OTP in datbase
78 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 })
88
89 otpID = result.id
90 } catch (e) {
91 payload.logger.error(e, 'Failed to save OTP.')
92
93 return 'Failed to save OTP'
94 }
95
96 // Send the OTP to the user
97 try {
98 // TODO: Remove this when the email service is setup
99 payload.logger.info(`Sending OTP to ${email}. OTP: ${otp}`)
100
101 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>
109
110 <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>
113
114 <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.')
124
125 // Delete the OTP from the database
126 await payload.delete({
127 collection: 'otp',
128 id: otpID,
129 })
130
131 return 'Failed to send OTP'
132 }
133
134 return true
135}
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.

  1. Create the OTP input field.
1// components/login/components/otp/index.tsx
2
3'use client'
4
5import { Button, useField, toast, NumberField, useTranslation } from '@payloadcms/ui'
6import { sendOtp } from './send-otp'
7import { useState } from 'react'
8
9export function OTPComponent() {
10 const { value } = useField<string>({ path: 'email' })
11 const { t } = useTranslation()
12
13 const [isSubmitting, setIsSubmitting] = useState(false)
14
15 return (
16 <div
17 style={{
18 display: 'flex',
19 gap: '1rem',
20 }}
21 className="login-otp-input-parent"
22 >
23 <NumberField
24 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 }
34
35 if (value.toString().length !== 6) {
36 return 'OTP must be 6 digits'
37 }
38
39 return true
40 }}
41 />
42
43 <Button
44 type="button"
45 size="large"
46 buttonStyle="primary"
47 disabled={isSubmitting}
48 onClick={async () => {
49 setIsSubmitting(true)
50
51 const result = await sendOtp(value)
52
53 if (result !== true) {
54 toast.error(result)
55 } else {
56 toast.success('OTP sent successfully')
57 }
58
59 setIsSubmitting(false)
60 }}
61 >
62 Send OTP
63 </Button>
64 </div>
65 )
66}
67
  1. Create the login form component
1// components/login/components/login-form.tsx
2
3'use client'
4
5import 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'
21
22const baseClass = 'login__form'
23
24export const LoginForm: React.FC<{
25 prefillEmail?: string
26 prefillPassword?: string
27 searchParams: { [key: string]: string | string[] | undefined } | undefined
28}> = ({ prefillEmail, prefillPassword, searchParams }) => {
29 const { config } = useConfig()
30
31 const {
32 admin: {
33 routes: { forgot: forgotRoute },
34 },
35 routes: { admin: adminRoute },
36 } = config
37
38 const { t } = useTranslation()
39 const { setUser } = useAuth()
40
41 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 }
53
54 initialState.email = {
55 initialValue: prefillEmail ?? undefined,
56 valid: true,
57 value: prefillEmail ?? undefined,
58 }
59
60 const handleLogin = (data: UserWithToken) => {
61 setUser(data)
62 }
63
64 return (
65 <Form
66 action={`/api/login`}
67 className={baseClass}
68 disableSuccessStatus
69 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 waitForAutocomplete
76 >
77 <div className={`${baseClass}__inputWrap`}>
78 <EmailField
79 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 <PasswordField
92 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 true
103 }}
104 />
105 <OTPComponent />
106 </div>
107
108 <Link
109 href={formatAdminURL({
110 adminRoute,
111 path: forgotRoute,
112 })}
113 prefetch={false}
114 >
115 {t('authentication:forgotPasswordQuestion')}
116 </Link>
117
118 <FormSubmit size="large">{t('authentication:login')}</FormSubmit>
119 </Form>
120 )
121}
122
  1. Create the login view component
1// components/login/index.tsx
2
3import { 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'
9
10export const loginBaseClass = 'login'
11
12export const LoginView: React.FC<AdminViewServerProps> = ({ searchParams, initPageResult }) => {
13 const { req } = initPageResult
14
15 const {
16 payload: { config },
17 user,
18 } = req
19
20 const {
21 routes: { admin },
22 } = config
23
24 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 }
33
34 const prefillAutoLogin =
35 typeof config.admin?.autoLogin === 'object' && config.admin?.autoLogin.prefillOnly
36
37 const prefillEmail =
38 prefillAutoLogin && typeof config.admin?.autoLogin === 'object'
39 ? config.admin?.autoLogin.email
40 : undefined
41
42 const prefillPassword =
43 prefillAutoLogin && typeof config.admin?.autoLogin === 'object'
44 ? config.admin?.autoLogin.password
45 : undefined
46
47 return (
48 <MinimalTemplate>
49 <div className={`${loginBaseClass}__brand`}>
50 <PayloadLogo />
51 </div>
52 <LoginForm
53 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.ts
2
3import { NextResponse } from 'next/server'
4import { hashWithHmac } from '../../lib/hash'
5import { getPayload, headersWithCors, type User } from 'payload'
6import config from '@payload-config'
7
8interface IPayloadLoginForm {
9 email?: string
10 otp?: number
11 password?: string
12}
13
14export async function POST(req: Request): Promise<Response> {
15 const formData = await req.formData()
16
17 const form = formData.get('_payload') as string | null
18
19 if (!form) {
20 return NextResponse.json(
21 {
22 errors: [{ message: 'Invalid form submission.' }],
23 },
24 { status: 401 },
25 )
26 }
27
28 const parsedFormData = JSON.parse(form) as IPayloadLoginForm
29
30 const { email, otp, password } = parsedFormData
31
32 // Validate form fields
33 if (!email || !otp || !password) {
34 return NextResponse.json(
35 {
36 errors: [{ message: 'Invalid form submission.' }],
37 },
38 { status: 401 },
39 )
40 }
41
42 const payload = await getPayload({ config })
43
44 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 })
57
58 const user = userResult.docs[0]
59
60 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 }
74
75 // Check if user is locked out
76 if (user.lockUntil) {
77 const lockUntil = new Date(user.lockUntil).getTime()
78
79 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 }
94
95 // 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 })
113
114 if (!otpDocs.docs[0]) {
115 // Increase user login attempt count. Lock account if max attempts reached.
116 const { maxLoginAttempts, lockTime } = payload.collections.users.config.auth
117
118 const updatePayload: Pick<User, 'loginAttempts' | 'lockUntil'> = {
119 loginAttempts: Number(user.loginAttempts) + 1,
120 lockUntil: null,
121 }
122
123 if (updatePayload.loginAttempts >= maxLoginAttempts) {
124 updatePayload.lockUntil = new Date(Date.now() + lockTime).toISOString()
125 }
126
127 await payload.update({
128 collection: 'users',
129 id: user.id,
130 data: updatePayload,
131 })
132
133 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 }
146
147 /**
148 * If OTP is good then process the rest of the login logic.
149 * 1. Extract method, headers, and body from the request
150 * 2. Clone headers and remove Content-Type and Content-Length headers
151 * 3. Extract form data from the incoming request
152 * 4. Call the users login route with headers and form data
153 * 5. Extract headers and body from the response
154 * 6. Set Content-Encoding to identity to prevent Next.js from trying to decompress the response
155 * 7. Return the response
156 */
157 const { method, headers: originalHeaders } = req
158
159 const modifiedHeaders = new Headers(originalHeaders)
160
161 modifiedHeaders.delete('Content-Type')
162 modifiedHeaders.delete('Content-Length')
163
164 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 })
175
176 // 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 }
187
188 const usersLoginResponseHeaders = new Headers(usersLoginResponse.headers)
189
190 // Set Content-Encoding to identity to prevent Next.js from trying to decompress the response
191 usersLoginResponseHeaders.set('Content-Encoding', 'identity')
192
193 const usersLoginResponseBody = await usersLoginResponse.text()
194
195 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.ts
2
3export 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:

  1. Overwrite the English translation object with our customized one.
1// src/payload.config.ts
2
3import { enTranslations } from './app/(payload)/translations/en'
4
5export default buildConfig({
6 //....
7 i18n: {
8 translations: {
9 en: enTranslations,
10 },
11 },
12 //....
13})
  1. Update the default login route as follows:
1// src/payload.config.ts
2
3import { enTranslations } from './app/(payload)/translations/en'
4
5export default buildConfig({
6 //....
7 admin: {
8 // Moving default login page to deprecated route.
9 routes: {
10 login: '/deprecated-login',
11 },
12 //...
13 },
14 //....
15})
  1. Register our custom Login component in the /login path
1// src/payload.config.ts
2
3import { enTranslations } from './app/(payload)/translations/en'
4
5export 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.mjs
2
3import { withPayload } from '@payloadcms/next/withPayload'
4
5/** @type {import('next').NextConfig} */
6const nextConfig = {
7 // ...
8 redirects: async () => {
9 return [
10 // Redirect from payloads default login to our custom login path
11 {
12 source: '/admin/deprecated-login',
13 destination: '/admin/login',
14 permanent: true,
15 },
16 ]
17 },
18}
19
20export 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.