In this article, we will go through adding two-factor authentication (2FA) into a Strapi CMS application, with a frontend built using Next.js. The goal is to improve user account security by adding a one-time-password (OTP) email verification and Time-based One-time Password (TOTP) authenticator app support. This tutorial covers both backend and frontend development to provide a complete authentication experience.
Here is a demo of what we will be building:
This article is divided into two parts:
Our first approach to implementing 2FA is through email-based OTP.
When a user attempts to log in with their correct identifier and password, our application will generate a random OTP and send it to their registered email address. The user will then need to retrieve the OTP from their email and enter it on the OTP verification page to complete the authentication.
This method is widely used because it does not require the installation of additional apps or tools, just an active email address. While it is not as secure as app-based authentication, it is still a significant improvement over relying solely on passwords.
We will start with creating our Strapi v5 application, configuring email services, create a custom content type for storing OTPs, and integrate the necessary logic for OTP generation and verification during user login.
Let's create a new Strapi 5 project using the command below:
npx create-strapi-app@latest strapi-2fa --quickstart
or
yarn create strapi
Strapi headless CMS provides an Email plugin that allows integration with popular email providers such as SendGrid, Mailgun, Nodemailer, etc. In our case, we will set up email functionality for the project using the Strapi Nodemailer plugin as the preferred email provider and MailDev for local testing.
MailDev is a simple email server for testing project's generated email during development, with an easy to use web interface. If you are unfamiliar with the Strapi Email plugin or want to learn more about setting up and sending email in Strapi app, checkout the Strapi's Email plugin documentation for detailed instructions.
To install the Strapi Nodemailer plugin in our Strapi project, we will run this command below:
# using yarn
yarn add @strapi/provider-email-nodemailer
# using npm
npm install @strapi/provider-email-nodemailer --save
We need to set up our project's email plugin to use the nodemailer provider that we have just installed. To do this, we will open the config/plugins.ts
file and add the code:
1export default ({ env }) => ({
2 email: {
3 config: {
4 provider: "nodemailer",
5 providerOptions: {
6 host: "localhost",
7 port: 1025,
8 ignoreTLS: true,
9 },
10 },
11 },
12});
This sets up Nodemailer as the email provider for our application, using 1025
as the mail server port for MailDev.
With our application now capable of sending emails, we'll want to be able to inspect these emails during development on our local machine. Our next step is to install MailDev.
$ docker run -p 1080:1080 -p 1025:1025 maildev/maildev
This Docker command runs a MailDev container, mapping ports 1080
and 1025
, which allows us to view emails sent by the application on "http://localhost:1080", and send emails from our application to MailDev on port 1025
.
We need a way to generate, store, and manage the OTPs for each user. In Strapi, we can achieve this by creating a specific Content Type for OTPs. This content type will include fields for storing the OTP code, its expiration date and time, and the associated user.
To create a new content type for the OTP, open Strapi admin, and navigate to the Content-Type Builder
page. Let us go through the steps of creating the OTP content type in Strapi. Click Create new collection type under the Collection Type section.
Follow these steps to complete the collection type setup:
Step 1. Collection Type Name:
OTP
. The API ID (Singular) and API ID (plural) fields will be set to otp
and otps
respectively. Step 2. Add Fields:
code
and select "Short Text" as the type. Make the field required in the "ADVANCED SETTINGS" tab.expiresAt
. Mark it as a required field.Step 3. Save the Content Type:
Step 4. Verify in Content Manager:
OTP
collection type listed. This is where OTP records will be stored when we generate them.Here are the OTP Content Type image tips:
Create OTP Content Type
Disable Draft and Publish
Fields of OTP Content Type
Now that we have set up the OTP content type, our next task is to extend the user registration process to align with our 2FA system. Instead of automatically issuing and returning a JWT as Strapi does by default, we will return only a success
field that indicates whether the registration was successful or not.
Open src/api/otp/controllers/otp.ts
and add this register
action:
1import { factories } from "@strapi/strapi";
2
3export default factories.createCoreController("api::otp.otp", ({ strapi }) => ({
4 async register(ctx, next) {
5 await strapi.controllers["plugin::users-permissions.auth"].register(
6 ctx,
7 next
8 );
9
10 ctx.send({ success: true });
11 },
12}));
We extend the default user registration process by calling the users-permissions
plugin's register
method, intercept the response, and send a simple success message ({ success: true })
as a response.
Calling the registration endpoint /auth/local/register
will trigger the default register
method from the users-permissions
plugin, not the custom register
action in the otp
controller. To change this behavior, we need to register a custom route for the otp
route, which will override the users-permissions
plugin register
route.
Create a new file named custom.ts
inside the src/api/otp/routes/
directory, and enter the code:
1export default {
2 routes: [
3 {
4 method: "POST",
5 path: "/auth/local/register",
6 handler: "api::otp.otp.register",
7 config: {
8 auth: false,
9 middlewares: ["plugin::users-permissions.rateLimit"],
10 prefix: "",
11 },
12 },
13 ],
14};
Strapi prioritizes custom routes over plugin routes, and because our custom registration route and the users-permissions
plugin share the same path \auth\local\register
, the custom route handler api::otp.otp.register
will be triggered for this specific endpoint.
Just like the registration, we will modify the standard login flow to ensure that users verify an OTP before gaining full access. Instead of the default behavior of returning the user's information and JSON Web Token (JWT) after a successful login, we will adjust the flow to return only the user’s email address and the type of OTP verification to use.
This will force the user to successfully verify the OTP sent to their email address before the JWT token is issued.
We will be working with Date and time in the coming sections. For that, let us install the date-fns npm package for access to some useful date time functions:
# yarn
yarn add date-fns
# npm
npm install date-fns
Let us visit the src/api/otp/controllers/otp.ts
file again and modify it to add our custom login
action.
1...
2import { randomInt } from "crypto";
3import { addMinutes } from "date-fns";
4
5export default factories.createCoreController('api::otp.otp', ({strapi})=>({
6 ...
7
8 async login(ctx, next) {
9 const provider = ctx.params.provider || "local";
10
11 await strapi.controllers["plugin::users-permissions.auth"].callback(
12 ctx,
13 next
14 );
15
16 if (provider === "local" || provider === "email") {
17
18 try {
19 const body: any = ctx.body;
20
21 const user = await strapi
22 .documents("plugin::users-permissions.user")
23 .findOne({ documentId: body.user.documentId });
24
25 const verifyType = "otp";
26 const now = new Date(new Date().toISOString());
27 const expiresAt = addMinutes(now, 30);
28 const code = randomInt(1000_000).toString().padStart(6, "0");
29
30 const otpEntry = await strapi.documents("api::otp.otp").create({
31 data: {
32 code,
33 expiresAt,
34 user: user.id,
35 },
36 });
37
38 await strapi
39 .plugin("email")
40 .service("email")
41 .send({
42 to: user.email,
43 from: "noreply@example.com",
44 subject: "Login OTP",
45 text: `Your login OTP is: ${otpEntry.code}`,
46 });
47
48 ctx.send({ email: user.email, verifyType });
49 } catch (err) {
50 ctx.body = err;
51 }
52 }
53 },
54}));
In our custom login
action, we start by calling the login callback
action from the users-permissions
plugin to process the user credentials and validate the login attempt, then we check if the user is logging in through the "local" or "email" provider.
In the case of local/email provider login, we fetch the user’s details, generates an OTP, store it with a 30-minute expiration window, and send it to the user's registered email address.
Finally, let us override the login route by adding the login route to the custom route list in src/api/otp/routes/custom.ts
:
1{
2 method: "POST",
3 path: "/auth/local",
4 handler: "api::otp.otp.login",
5 config: {
6 auth: false,
7 middlewares: ["plugin::users-permissions.rateLimit"],
8 prefix: "",
9 },
10}
We will create a verifyOtp
method in the otp
service to manage the OTP verification logic. This method will accept the user's email
and code
as parameters, and validate them together. Also, it will ensure the OTP is still valid by checking its expiration time. If the OTP is found but expired, the verification will fail.
Upon successful verification, we will issue the user a JWT token and return it along with their user information as a response to complete the login process.
Let us update the src/api/otp/services/otp.ts
file with the following code:
1import { factories } from "@strapi/strapi";
2import { isAfter } from "date-fns";
3
4export default factories.createCoreService("api::otp.otp", ({ strapi }) => ({
5 async verifyOtp(email: string, code: string) {
6 const otpEntry = await strapi.db.query("api::otp.otp").findOne({
7 where: {
8 code,
9 user: { email: { $eq: email } },
10 },
11 });
12
13 if (!otpEntry) return false;
14
15 const now = new Date().toISOString();
16
17 if (!isAfter(otpEntry.expiresAt, now)) return false;
18
19 await strapi
20 .documents("api::otp.otp")
21 .delete({ documentId: otpEntry.documentId });
22
23 return true;
24 },
25}));
Let us go ahead and create a new method, and name it verifyCode
in the otp
controller, where we will call the verifyOtp
service we created earlier.
Navigate to src/api/otp/controllers/otp.ts
, and these lines belowimport { addMinutes } from "date-fns";
:
1import utils from "@strapi/utils";
2const { ValidationError } = utils.errors;
3
4const sanitizeUser = (user, ctx) => {
5 const { auth } = ctx.state;
6 const userSchema = strapi.getModel("plugin::users-permissions.user");
7
8 return strapi.contentAPI.sanitize.output(user, userSchema, { auth });
9};
Next, let us create the verifyCode
method after the login
method in the controller:
1async verifyCode(ctx) {
2 const { code, email } = ctx.request.body;
3
4 const user = await strapi.db
5 .query("plugin::users-permissions.user")
6 .findOne({ where: { email } });
7
8 if (!user) throw new ValidationError("Code verification failed");
9
10 let isValid = false;
11
12 isValid = await strapi.service("api::otp.otp").verifyOtp(email, code);
13
14 if (!isValid) throw new ValidationError("Code verification failed");
15
16 const userDto: any = await sanitizeUser(user, ctx);
17
18 ctx.send({
19 jwt: strapi.plugins["users-permissions"].services.jwt.issue({
20 id: userDto.id,
21 }),
22 user: await sanitizeUser(userDto, ctx),
23 });
24},
To wrap up the email OTP verification process, open the route file src/api/otp/routes/custom.ts
and add the route for the verifyCode
method:
1{
2 method: "POST",
3 path: "/auth/verify-code",
4 handler: "api::otp.otp.verifyCode",
5 config: {
6 auth: false,
7 middlewares: ["plugin::users-permissions.rateLimit"],
8 },
9},
All right, we have successfully integrated email OTP verification into our Strapi application. In the coming sections, we'll explore how to add support for authenticator app verification to further enhance the security of our Strapi application.
We will extend our two-factor authentication setup by adding support for TOTP using an authenticator app. While the previous sections focused on OTP verification via email, this method offers stronger security and convenience by generating time-sensitive codes on the user’s mobile device through apps like Microsoft Authenticator, Google Authenticator, etc.
With TOTP, users scan a TOTP QR code during setup to link their account with an authenticator app, which will then generate new codes every 30 seconds. This adds an extra layer of protection, as the codes are device-based and not vulnerable to phishing or email compromises.
In the steps ahead, we will modify the existing Users-Permissions plugin to store and verify TOTP secrets, generate TOTP QR codes for easy setup, and allow users to verify their login with the code generated in their authenticator app.
Let's start by extending the User
model fields in users-permissions
plugin. Open the src/index.ts
file, and modify the register
function like so:
1register({ strapi }: { strapi: Core.Strapi }) {
2 const contentTypeName = strapi.contentType(
3 "plugin::users-permissions.user"
4 );
5
6 contentTypeName.attributes = {
7 ...contentTypeName.attributes,
8 totpSecret: {
9 type: "string",
10 private: true,
11 configurable: false,
12 },
13 enableTotp: {
14 type: "boolean",
15 default: false,
16 configurable: false,
17 },
18 };
19},
Add this line at the top of the file:
1import type { Core } from "@strapi/strapi";
We added two new attributes to the user content type to support TOTP-based authentication.
totpSecret
: This is a private string field that stores the user’s TOTP secret. We also set the configuration
option to false
to prevent it from modification through the Strapi admin panel.
enableTotp
: Through this field we can indicate whether the user has enabled TOTP authentication for their account. We also marked as non-configurable.
When a user opts to enable TOTP, our application will generate a unique secret key, which the user can scan as a QR code or enter manually in their authenticator app. This secret will then be saved in the user’s profile using the totpSecret
field we added earlier.
During the TOTP setup, when we generate the user's TOTP secret for the first time, we want to allow the user to validate the secret before saving it to their profile. This way we can be sure that the user's authenticator app successfully saved the TOTP secret.
time2fa
PackageNext, let's install the time2fa package we will use to generate and validate TOTP secrets:
# yarn
yarn add time2fa
# npm
npm install time2fa
Let's navigate to the otp
controller src/api/otp/controllers/otp.ts
, and add the necessary code for TOTP secret generation.
Update the line const { ValidationError } = utils.errors;
to: const { ValidationError, ApplicationError } = utils.errors;
Import the time2fa
package in the controller file:
1import { Totp } from "time2fa";
Next, let us create the generateTotpSecret
action in the controller:
1async generateTotpSecret(ctx) {
2 if (!ctx.state.user) {
3 throw new ApplicationError(
4 "You must be authenticated to setup Authentication App"
5 );
6 }
7
8 const data = Totp.generateKey({
9 issuer: "StrapiOtp",
10 user: ctx.state.user.email,
11 });
12
13 ctx.send({ email: data.user, secret: data.secret, url: data.url });
14},
Go to otp
custom route list and add the route for the generateTotpSecret
action:
1{
2 method: "POST",
3 path: "/auth/generate-totp-secret",
4 handler: "api::otp.otp.generateTotpSecret",
5},
Since the generateTotpSecret
action requires authentication, we need to enable access for authenticated users through the Strapi admin panel.
Navigate to Settings > Roles, under the Users & Permissions Plugin section, select the Authenticated Role. Expande the Otp section and check generateTotpSecret
.
After the user adds the TOTP secret to their authenticator app, they will need to submit a TOTP code generated by the app. This code, along with the secret, will be sent for validation, and if successful, the app will store the secret in the totpSecret
field and enable the enableTotp
field in the user's information.
After enabling TOTP for a particular user, we also create an endpoint which the frontend application will call to check if TOTP is enabled for the logged in user.
Let us navigate back to the otp
controller class src/api/otp/controllers/otp.ts
and create the methods for saving and checking if TOTP is enabled: saveTotpSecret
and totpEnabled
respectively:
1async saveTotpSecret(ctx) {
2 if (!ctx.state.user) {
3 throw new ApplicationError(
4 "You must be authenticated to setup Authentication App"
5 );
6 }
7
8 const { secret, code } = ctx.request.body;
9 const success = Totp.validate({ passcode: code, secret });
10
11 if (!success) {
12 throw new ValidationError("Secret and code validation failed");
13 }
14
15 await strapi.plugins["users-permissions"].services.user.edit(
16 ctx.state.user.id,
17 {
18 totpSecret: secret,
19 enableTotp: true,
20 }
21 );
22
23 ctx.send({ success });
24},
25
26async totpEnabled(ctx) {
27 const user = await strapi
28 .documents("plugin::users-permissions.user")
29 .findOne({ documentId: ctx.state.user.documentId });
30
31 const enabled = user.enableTotp && user.totpSecret;
32
33 ctx.send({ enabled });
34},
Next, let's go to the otp
route list and add the routes for the saveTotpSecret
and totpEnabled
methods:
1{
2 method: "POST",
3 path: "/auth/save-totp-secret",
4 handler: "api::otp.otp.saveTotpSecret",
5},
6{
7 method: "GET",
8 path: "/auth/totp-enabled",
9 handler: "api::otp.otp.totpEnabled",
10},
Once again, the saveTotpSecret
and totpEnabled
endpoints require authentication. Therefore, we have to follow the same procedure as we did for the generateTotpSecret
endpoint to grant access to authenticated users for the saveTotpSecret
and totpEnabled
endpoints in the admin panel.
When a user logs in with TOTP enabled, they will be prompted to submit the current code generated from their authenticator app. Our app will validate this code using the totpSecret
to ensure that the submitted code matches the one generated for that specific time window. If the code is valid, the user will receive a JWT token and their user's information.
To implement this functionality, we need to modify the otp
service by adding the verifyTotp
method. Open the src/api/otp/services/otp.ts
file and make the following changes:
First, we import the Totp
module from the time2fa
library, which we will use to validate the TOTP codes:
1import { Totp } from "time2fa";
Now, we define the verifyTotp
method to handle the validation of the TOTP codes:
1async verifyTotp(code: string, secret: string) {
2
3 const isValid = Totp.validate({ passcode: code, secret });
4
5 if (!isValid) return false;
6
7 return true;
8},
Currently, our login
and verifyCode
actions only supports OTP verification via email. To enable TOTP-based verification, we will modify the logic in these actions to include TOTP validation using the new verifyTotp
method.
Open the otp
controller file src/api/otp/controllers/otp.ts
and update the login
and verifyCode
methods, respectively:
1async login(ctx, next) {
2 const provider = ctx.params.provider || "local";
3
4 await strapi.controllers["plugin::users-permissions.auth"].callback(
5 ctx,
6 next
7 );
8
9 if (provider === "local" || provider === "email") {
10
11 try {
12 const body: any = ctx.body;
13
14 const user = await strapi
15 .documents("plugin::users-permissions.user")
16 .findOne({ documentId: body.user.documentId });
17
18 let verifyType: "otp" | "totp" = "totp";
19
20 if (!user.enableTotp || !user.totpSecret) {
21 verifyType = "otp";
22 const now = new Date(new Date().toISOString());
23 const expiresAt = addMinutes(now, 30);
24 const code = randomInt(1000_000).toString().padStart(6, "0");
25
26 const otpEntry = await strapi.documents("api::otp.otp").create({
27 data: {
28 code,
29 expiresAt,
30 user: user.id,
31 },
32 });
33
34 await strapi
35 .plugin("email")
36 .service("email")
37 .send({
38 to: user.email,
39 from: "noreply@example.com",
40 subject: "Login OTP",
41 text: `Your login OTP is: ${otpEntry.code}`,
42 });
43 }
44
45 ctx.send({ email: user.email, verifyType });
46 } catch (err) {
47 ctx.body = err;
48 }
49 }
50},
51
52async verifyCode(ctx) {
53 const { code, email, type } = ctx.request.body;
54
55 const user = await strapi.db
56 .query("plugin::users-permissions.user")
57 .findOne({ where: { email } });
58
59 if (!user) throw new ValidationError("Code verification failed");
60
61 let isValid = false;
62
63 if (type === "totp") {
64 isValid = await strapi
65 .service("api::otp.otp")
66 .verifyTotp(code, user.totpSecret);
67 } else {
68 isValid = await strapi.service("api::otp.otp").verifyOtp(email, code);
69 }
70
71 if (!isValid) throw new ValidationError("Code verification failed");
72
73 const userDto: any = await sanitizeUser(user, ctx);
74
75 ctx.send({
76 jwt: strapi.plugins["users-permissions"].services.jwt.issue({
77 id: userDto.id,
78 }),
79 user: await sanitizeUser(userDto, ctx),
80 });
81},
The login
method determines the verification method by checking if the enableTotp
field is false or if the totpSecret
field is empty. If either condition is met, it sets the verification type to OTP and sends the code via email. Otherwise, it sets the verification type to TOTP, which indicates that the authenticator app will be used for verification.
The verifyCode
method checks the type
parameter to determine which verification service method to use. If the type
is totp, it calls the verifyTotp
service method, passing the code and the totpSecret
associated with the user. Otherwise, it defaults to verifying the code via the verifyOtp
service method.
We have successfully implemented two-factor authentication for our Strapi application. Users now can choose between email-based OTP and authenticator app-based TOTP for secure access to their accounts.
Next, we will shift our focus to implementing the front-end user interface using Next.js.
In this part of the article, we focused on setting up two-factor authentication (2FA) within Strapi headless CMS. We built the backend foundation by creating the necessary content types and API endpoints to manage OTP-based email verification and Time-based One-time Password (TOTP) via authenticator apps.
With the backend in place, our Strapi application is now ready to support multi-factor authentication. However, a complete authentication flow requires a frontend that interacts with the backend.
In the second part of this article, we move to the frontend, where we will build the registration, login, and authentication pages using Next.js. We will also implement TOTP QR code generation for TOTP setup to ensure a smooth user experience.
Emeka is a skilled software developer and educator in web development, mentoring, and collaborating with teams to deliver business-driven solutions.