This is my approach to implementing 2FA without completely replacing Payload's default authentication systems. It was a requirement in one of my client projects.
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.
My best option is to extend Payload's auth implementation. Fortunately, Payload (along with NextJS) is very extensible so I have been able to come up with a simple solution.
Here are the steps we used to implement a custom authentication flow:
-
Create a collection to store OTP codes, we call it
otp
.In an ideal world, these would be stored in a Redis database, for simplicity of the guide I am creating this withing our MongoDB. If you keep this collection with Payload then make sure to do the following:
- Set all access set to
false
. That way it is only accessible by theLocal API
. - Create an index for the
expiresAt
property and make sure its aTTL
index with appropriate expiration time
- Set all access set to
-
Create the Login UI to replace Payload's default. This is largely based on Payload's original login code here. I have made just enough tweaks to support and additional OTP input.
- Create a function to hash our OTP codes. You can find the relevant file here.
- Create the Login UI. I will list out the files in order of creation to avoid import hassles
- OTP Email Server Action
In this example it will print the OTP in the console. Once you have a SMTP setup you can remove the console.log
- OTP Field
- Login Form
- Login View
- OTP Email Server Action
-
Create a route handler to implement the necessary logic to receive a login form POST request and validate the OTP. Here are some notes on this endpoint
- 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.
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 clone of Payload's
en
translations and overwrite the translation foremailOrPasswordIncorrect
, to include theotp
keyword. This is to ensure the error messages are ambiguous and no information is leaked.We are using Payload's default login endpoint, which uses Payload's default translations. If we try to only overwrite the
en.emailOrPasswordIncorrect
property, the wholeen
translation object gets overwritten, so we had to import their full translation object and update theemailOrPasswordIncorrect
property ourselves. -
Next you need to make the following updates to the Payload config file:
- Update the default login route to be
deprecated-login
. - Add a
customLogin
property to thecomponents.views
property. Set the route for our custom login component as/login
- Update the
i18n.translations.en
property with our customiseden
translations object.
Don't forget to run importmap
- Update the default login route to be
-
Update
next.config
to permanently redirect any request to/admin/deprecated-login
(the new Payload default login route) to/login
This solution works due to the following reasons.
-
The custom views in Payload are public by default
-
We have changed Payload's default login route to
/deprecated-login
and set a permanent redirect in NextJS to redirect users to/admin/login
from/admin/deprecated-login
. -
The custom view in
/login
is identical to Payload's default login and re-uses as much code as possible from Payload's codebase.
- 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.