~/zarif
Back to Blog

Track 01 · 2025-02-23

Payload CMS: 2FA Implementation

PayloadArchitecture

Overall Plan

Payload has a robust JWT authentication implementation. I didn't have time to replace it with a custom auth layer that would match the same security guarantees, so instead I wanted to layer an OTP input on top of the existing login screen, keeping Payload's default login logic intact.

The approach: add an OTP step to the login flow, validate the code before forwarding credentials to Payload's own authentication endpoint.

Steps

1. Create an OTP collection

Ideally you'd store OTP codes in Redis, but for simplicity we're using Payload's MongoDB database.

TypeScript
1export const OTP: CollectionConfig = {
2 slug: 'otp',
3 admin: { hidden: true },
4 access: {
5 create: () => false,
6 read: () => false,
7 update: () => false,
8 delete: () => false,
9 },
10 fields: [
11 { name: 'userEmail', type: 'email', required: true, index: true },
12 { name: 'otpCode', type: 'text', required: true },
13 { name: 'expiresAt', type: 'date', required: true },
14 { name: 'isUsed', type: 'checkbox', defaultValue: false },
15 ],
16}

Include this in the collections array of your Payload config and run generate:types. In MongoDB, create a TTL index on the expiresAt field so documents are auto-deleted after expiration.

2. Create a custom login UI

I recreated Payload's default login screen and added an OTP input field. First, a utility function to hash OTP codes using HMAC-SHA256 with Payload's secret:

TypeScript
1export function hashWithHmac(value: string): string {
2 const secret = process.env.PAYLOAD_SECRET
3 if (!secret) throw new Error('Failed to generate hash.')
4 return createHmac('sha256', secret).update(value).digest('hex')
5}

Then a server action to generate and send the OTP via email; it checks if the user exists, whether they're locked out, and if a valid OTP was already sent within the last minute. The code generates a random 6-digit OTP, hashes it, stores it in the OTP collection, and sends it to the user.

Next, a client-side OTP input component using Payload's NumberField with a "Send OTP" button. And a login form component that includes the email field, password field, and the OTP component, all wired to submit to a custom route handler.

3. Create the login route handler

A custom /api/login route that intercepts the login request:

  • Validates the OTP against the database (hashed comparison, expiry check, not already used)
  • On failure: returns Payload's translation-based error messages (consistent UX)
  • On success: forwards the request to Payload's default /api/users/login endpoint and marks the OTP as used

If the OTP is invalid, login attempts are tracked and the account locks after the configured max attempts, matching Payload's built-in brute-force protection.

4. Overwrite English translations

Since we forward to Payload's default endpoint, error messages come from Payload's translation system. To keep messages ambiguous (e.g., "The email, password or OTP provided is incorrect"), I overrode the English translation object in Payload's config.

5. Update Payload config

Three changes to payload.config.ts:

  • Translations: Point i18n.translations.en to the custom translation file
  • Route: Move the default login route to /deprecated-login so Payload doesn't serve its own login page
  • Custom view: Register the custom LoginView component at the /login path

6. Update Next.js config

Add a permanent redirect from /admin/deprecated-login to /admin/login so logged-out users are redirected to the custom login screen.

Known Issues

  • Error handling / translations: Overwriting the entire English translation object doesn't scale well for multi-language support. This needs a better approach.
  • CORS: I wasn't able to fully test CORS behavior with this setup.

If I resolve either of these I'll update the GitHub repository.