In this part, we will create the frontend user interface for our Strapi CMS application using Next.js. Our focus will be on developing a registration page, a login page, and One-time Password (OTP) verification processes for both email and Time-based one-time password (TOTP) through an authenticator app.
This article is divided into two parts:
We will implement the setup for the authenticator app to allow users to easily enable their accounts for secure authentication.
Here are the core pages we will be building to test our Strapi application:
Let us start by creating a new Next.js app with App Router and TypeScript enabled:
npx create-next-app@latest front-2fa --use-npm # or --use-yarn
We will set up Iron Session to manage user sessions in our Next.js application. Iron Session is a lightweight library that is secure, stateless, and cookie-based session. Session data is stored in signed and encrypted cookies which are decoded by the server code in a stateless fashion.
Let us add iron session to our Next.js project:
npm install iron-session
# or
yarn add iron-session
Next, let us create a new file, src/app/auth/lib.ts
for the session configuration options and add the code:
1import { SessionOptions } from "iron-session";
2
3export interface SessionData {
4 username: string;
5 isLoggedIn: boolean;
6 jwt?: string;
7}
8
9export const defaultSession: SessionData = {
10 username: "",
11 isLoggedIn: false,
12};
13
14export const sessionOptions: SessionOptions = {
15 password: "complex_password_at_least_32_characters_long",
16 cookieName: "strapi-otp-app",
17 cookieOptions: {
18 httpOnly: true,
19 secure: process.env.NODE_ENV === "production",
20 },
21};
We create a SessionData
interface to structure session data (username, login status, and optional JWT). A defaultSession
that initializes the default session values, and sessionOptions
configures the session.
We need a way to determine whether a user is logged in. To achieve this, let's create a new file: src/app/auth/actions.ts
. This file will contain all the authentication-related server actions.
Next, we'll define a getSession
method to retrieve the user’s session data:
1"use server";
2
3import { cookies } from "next/headers";
4import { SessionData, defaultSession, sessionOptions } from "./lib";
5import { getIronSession } from "iron-session";
6
7export async function getSession() {
8 const useCookies = await cookies();
9 const session = await getIronSession<SessionData>(useCookies, sessionOptions);
10
11 if (!session.isLoggedIn) {
12 session.isLoggedIn = defaultSession.isLoggedIn;
13 session.username = defaultSession.username;
14 }
15
16 return session;
17}
As mentioned earlier, our application will have multiple pages, and we will need a navbar to help users move between them. Some of these pages will only be accessible to authenticated users. To manage access, we will use the iron session we set up earlier to track user login status and protect links or pages accordingly.
Let's create a new file: src/app/components/navbar.tsx
and add the code for the navigation:
1"use client";
2
3export default function NavBar({ isLoggedIn }: { isLoggedIn: boolean }) {
4 return (
5 <>
6 <nav className="relative px-4 py-4 flex justify-between items-center bg-white">
7 <a className="text-3xl font-bold leading-none" href="/">
8 Strapi 2FA
9 </a>
10
11 {isLoggedIn && <a href="/dashboard">Dashboard</a>}
12
13 {!isLoggedIn && (
14 <>
15 <a
16 className="hidden lg:inline-block lg:ml-auto lg:mr-3 py-2 px-6 bg-gray-50 hover:bg-gray-100 text-sm text-gray-900 font-bold rounded-xl transition duration-200"
17 href="/auth/login"
18 >
19 Sign In
20 </a>
21 <a
22 className="hidden lg:inline-block py-2 px-6 bg-blue-500 hover:bg-blue-600 text-sm text-white font-bold rounded-xl transition duration-200"
23 href="/auth/register"
24 >
25 Sign up
26 </a>
27 </>
28 )}
29 </nav>
30 </>
31 );
32}
Next, we will navigate to the layout file: src/app/layout.tsx
, import the NavBar
component and the get getSession
method from the actions file:
1import NavBar from "./components/navbar";
2import { getSession } from "./auth/actions";
After that, we modify the RootLayout
component:
1...
2
3export default async function RootLayout({
4 children,
5}: Readonly<{
6 children: React.ReactNode;
7}>) {
8 const session = await getSession();
9
10 return (
11 <html lang="en">
12 <body
13 className={`${geistSans.variable} ${geistMono.variable} antialiased`}
14 >
15 <NavBar isLoggedIn={session.isLoggedIn} />
16 {children}
17 </body>
18 </html>
19 );
20}
Let’s revisit the server actions file: src/app/auth/actions.ts
and make some modifications to integrate the registration functionality with our Strapi backend.
First, import the redirect
module from next/navigation
:
1import { redirect } from "next/navigation";
Next, define the base API URL for our Strapi application before the getSession
method:
1const API_URL = "http://localhost:1337/api";
Then, add the register
server action to handle user registration:
1export const register = async (prevState: any, formData: FormData) => {
2 const data = {
3 username: formData.get("username"),
4 email: formData.get("email"),
5 password: formData.get("password"),
6 };
7
8 const res = await fetch(`${API_URL}/auth/local/register`, {
9 method: "POST",
10 headers: { "Content-Type": "application/json" },
11 body: JSON.stringify(data),
12 });
13
14 const json = await res.json();
15
16 if (!res.ok) {
17 return { message: json?.error?.message || "Something went wrong" };
18 }
19
20 redirect(`/auth/login`);
21};
The register
function handles user registration by sending the username, email, and password from the FormData
object through a POST
request to the /auth/local/register
endpoint of our Strapi app.
If the registration is successful, the user is redirected to the login
page. In case of an error, it returns a message indicating what went wrong.
Lastly, let's create a new file, src/app/auth/register/page.tsx
, to implement the UI for the registration page:
1"use client";
2
3import { useActionState } from "react";
4import { register } from "../actions";
5
6const initialState = {
7 message: "",
8};
9
10export default function RegisterPage() {
11 const [state, formAction] = useActionState(register, initialState);
12
13 return (
14 <>
15 <section className="">
16 <div className="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
17 <div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
18 <div className="p-6 space-y-4 md:space-y-6 sm:p-8">
19 <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
20 Sign up your account
21 </h1>
22 <form className="space-y-4 md:space-y-6" action={formAction}>
23 {state?.message && (
24 <p className="text-sm pt-3 text-red-500">{state.message}</p>
25 )}
26 <div>
27 <label
28 htmlFor="username"
29 className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
30 >
31 Username
32 </label>
33 <input
34 type="text"
35 name="username"
36 className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
37 placeholder="Username"
38 />
39 </div>
40 <div>
41 <label
42 htmlFor="email"
43 className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
44 >
45 Email
46 </label>
47 <input
48 type="email"
49 name="email"
50 className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
51 placeholder="Email"
52 />
53 </div>
54 <div>
55 <label
56 htmlFor="password"
57 className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
58 >
59 Password
60 </label>
61 <input
62 type="password"
63 name="password"
64 placeholder="••••••••"
65 className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
66 />
67 </div>
68 <button
69 type="submit"
70 className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
71 >
72 Sign up
73 </button>
74 <p className="text-sm font-light text-gray-500 dark:text-gray-400">
75 Already have an account?{" "}
76 <a
77 href="/auth/login"
78 className="font-medium text-gray-600 hover:underline dark:text-gray-500"
79 >
80 Sign in
81 </a>
82 </p>
83 </form>
84 </div>
85 </div>
86 </div>
87 </section>
88 </>
89 );
90}
With the registration complete, we can now shift our focus to creating the user login.
Using the same approach as in the user registration, we will update the server actions file src/app/auth/actions.ts
to add the login and logout functionality.
Import the revalidatePath
module from next/cache
:
1import { revalidatePath } from "next/cache";
Here’s the code for the login
and logout
methods:
1export const login = async (prevState: any, formData: FormData) => {
2 const data = {
3 identifier: formData.get("identifier"),
4 password: formData.get("password"),
5 };
6
7 const res = await fetch(`${API_URL}/auth/local`, {
8 method: "POST",
9 headers: { "Content-Type": "application/json" },
10 body: JSON.stringify(data),
11 });
12
13 const json = await res.json();
14
15 if (!res.ok) {
16 return { message: json?.error?.message || "Something went wrong" };
17 }
18
19 redirect(`/auth/verify-code?e=${json.email}&vt=${json.verifyType}`);
20};
21
22export async function logout() {
23 const session = await getSession();
24 session.destroy();
25 revalidatePath("/dashboard");
26}
To implement the login page UI, create a new file: src/app/auth/login/page.tsx
, and add the code:
1"use client";
2import { useActionState } from "react";
3import { login } from "../actions";
4
5const initialState = {
6 message: "",
7};
8
9export default function LoginPage() {
10 const [state, formAction] = useActionState(login, initialState);
11
12 return (
13 <>
14 <section className="">
15 <div className="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
16 <div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
17 <div className="p-6 space-y-4 md:space-y-6 sm:p-8">
18 <h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
19 Sign in to your account
20 </h1>
21 <form className="space-y-4 md:space-y-6" action={formAction}>
22 {state?.message && (
23 <p className="text-sm pt-3 text-red-500">{state.message}</p>
24 )}
25 <div>
26 <label
27 htmlFor="identifier"
28 className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
29 >
30 Username or email
31 </label>
32 <input
33 type="text"
34 name="identifier"
35 className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
36 placeholder="Username or email"
37 />
38 </div>
39 <div>
40 <label
41 htmlFor="password"
42 className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
43 >
44 Password
45 </label>
46 <input
47 type="password"
48 name="password"
49 placeholder="••••••••"
50 className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
51 />
52 </div>
53 <button
54 type="submit"
55 className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
56 >
57 Sign in
58 </button>
59 <p className="text-sm font-light text-gray-500 dark:text-gray-400">
60 Don’t have an account yet?{" "}
61 <a
62 href="/auth/register"
63 className="font-medium text-gray-600 hover:underline dark:text-gray-500"
64 >
65 Sign up
66 </a>
67 </p>
68 </form>
69 </div>
70 </div>
71 </div>
72 </section>
73 </>
74 );
75}
Finally, let's update the navbar component src/app/components/navbar.tsx
code to include the logout functionality:
1"use client";
2
3import { useActionState } from "react";
4import { logout } from "../auth/actions";
5
6export default function NavBar({ isLoggedIn }: { isLoggedIn: boolean }) {
7 const [state, formAction] = useActionState(logout, null);
8
9 return (
10 <>
11 <nav className="relative px-4 py-4 flex justify-between items-center bg-white">
12 <a className="text-3xl font-bold leading-none" href="/">
13 Strapi 2FA
14 </a>
15
16 {isLoggedIn && <a href="/dashboard">Dashboard</a>}
17
18 <div>
19 {!isLoggedIn && (
20 <>
21 <a
22 className="hidden lg:inline-block lg:ml-auto lg:mr-3 py-2 px-6 bg-gray-50 hover:bg-gray-100 text-sm text-gray-900 font-bold rounded-xl transition duration-200"
23 href="/auth/login"
24 >
25 Sign In
26 </a>
27 <a
28 className="hidden lg:inline-block py-2 px-6 bg-blue-500 hover:bg-blue-600 text-sm text-white font-bold rounded-xl transition duration-200"
29 href="/auth/register"
30 >
31 Sign up
32 </a>
33 </>
34 )}
35 {isLoggedIn && (
36 <>
37 <form action={formAction}>
38 <button className="hidden lg:inline-block lg:ml-auto lg:mr-3 py-2 px-6 bg-gray-50 hover:bg-gray-100 text-sm text-gray-900 font-bold rounded-xl transition duration-200">
39 Logout
40 </button>
41 </form>
42 </>
43 )}
44 </div>
45 </nav>
46 </>
47 );
48}
The registration and login pages are now complete and functional. We can successfully register a new user and be redirected to the login page. However, if we log in, we won't be able to verify the OTP code sent to our email.
Next, we will work on email verification.
To handle OTP/TOTP verification on the frontend part, we will need to modify the server actions file: src/app/auth/actions.ts
as usual.
Next, add the codes for the verifyCode
function:
1export const verifyCode = async (prevState: any, formData: FormData) => {
2 const data = {
3 email: formData.get("email"),
4 code: formData.get("code"),
5 type: formData.get("type"),
6 };
7
8 if (!data.code) return { message: "'code' is required" };
9
10 const res = await fetch(`${API_URL}/auth/verify-code`, {
11 method: "POST",
12 headers: { "Content-Type": "application/json" },
13 body: JSON.stringify(data),
14 });
15
16 const json = await res.json();
17
18 if (!res.ok) {
19 return { message: json?.error?.message || "Something went wrong" };
20 }
21
22 const session = await getSession();
23
24 session.username = json.user.username;
25 session.isLoggedIn = true;
26 session.jwt = json.jwt;
27
28 await session.save();
29
30 revalidatePath("/dashboard");
31
32 redirect("/dashboard");
33};
The verifyCode
function extracts the email, code, and type from the FormData and sends a POST
request to the /auth/verify-code
endpoint. If the request fails, an error message is returned; otherwise, the session is updated with the user's details. Finally, the function refreshes the dashboard path and redirects the user to /dashboard
.
Now, let's add the UI for the verification page. Create a new file: src/app/auth/verify-code/page.tsx
.
Here is the code for the page:
1"use client";
2import { useActionState } from "react";
3import { verifyCode } from "../actions";
4import { useSearchParams } from "next/navigation";
5
6const initialState = {
7 message: "",
8};
9
10export default function VerifyCodePage() {
11 const searchParams = useSearchParams();
12 const email = searchParams.get("e") || "";
13 const verifyType = searchParams.get("vt") || "";
14 const typeTitle = verifyType === "otp" ? "OTP" : "Authenticator app";
15
16 const [state, useActionState] = useFormState(verifyCode, initialState);
17
18 return (
19 <>
20 <section className="">
21 <div className="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
22 <div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
23 <div className="p-6 space-y-4 md:space-y-6 sm:p-8">
24 <h1 className="text- text-center font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
25 Enter {typeTitle} Code
26 </h1>
27 <form className="space-y-4 md:space-y-6" action={formAction}>
28 {state?.message && (
29 <p className="text-sm text-center pt-1 text-red-500">
30 {state.message}
31 </p>
32 )}
33 <input type="hidden" name="email" value={email} />
34 <input type="hidden" name="type" value={verifyType} />
35 <div>
36 <input
37 type="text"
38 name="code"
39 autoComplete="off"
40 className="bg-gray-50 text-center border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
41 placeholder="Code"
42 />
43 </div>
44 <button
45 type="submit"
46 className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 text-center"
47 >
48 Verify
49 </button>
50 </form>
51 </div>
52 </div>
53 </div>
54 </section>
55 </>
56 );
57}
Let's create a new page for the dashboard: src/app/dashboard/page.tsx
.
Here is the code for the page:
1export default async function Page() {
2 return (
3 <>
4 <section>
5 <h2 className="py-8 text-3xl font-bold text-center">Dashboard</h2>
6 </section>
7 </>
8 );
9}
The dashboard page is currently minimal, containing only a heading. We will return to this page later to add the UI to set up an authenticator app.
At this stage, our application supports two-factor authentication (2FA), users can log in to using an OTP code sent to their email.
The verifyCode
function manages OTP or TOTP verification on the frontend. By default, newly registered users start with email-based OTP verification until they configure an authenticator app for their account. I will work on that next.
In this section, we will add the frontend functionality that allows users to set up an authenticator app for TOTP-based 2FA. This includes generating a QR code, which users can scan with an authenticator app like Google Authenticator or Microsoft Authenticator. Once scanned, the app will generate time-based one-time passwords that the user can use to log into their account.
Let’s revisit the src/app/auth/actions.ts
file and add the required code to set up TOTP.
1export async function generateSecret() {
2 const session = await getSession();
3
4 const res = await fetch(`${API_URL}/auth/generate-totp-secret`, {
5 method: "POST",
6 headers: {
7 "Content-Type": "application/json",
8 Authorization: `Bearer ${session.jwt}`,
9 },
10 });
11
12 const result = await res.json();
13
14 return result;
15}
16
17export async function saveTotpSecret(prevState: any, formData: FormData) {
18 const data = {
19 code: formData.get("code"),
20 secret: formData.get("secret"),
21 };
22
23 if (!data.code) return { message: "'code' is required" };
24
25 const session = await getSession();
26
27 const res = await fetch(`${API_URL}/auth/save-totp-secret`, {
28 method: "POST",
29 headers: {
30 "Content-Type": "application/json",
31 Authorization: `Bearer ${session.jwt}`,
32 },
33 body: JSON.stringify(data),
34 });
35
36 const json = await res.json();
37
38 if (!res.ok) {
39 return { message: json?.error?.message || "Something went wrong" };
40 }
41
42 redirect("/auth/app-setup-success");
43}
We add two new functions that handle generating and saving the TOTP secret for a user’s authenticator app setup.
The generateSecret
function generates the TOTP secret needed to set up a user's authenticator app. The server response from this function includes the user's email, the generated secret, and a URL, which will be used to create the QR code later in this section.
The saveTotpSecret
function saves the TOTP secret in the user's account details. When a TOTP secret is generated, we need to ensure that users have successfully saved it within their authenticator app. To verify this, we require users to submit the secret along with a TOTP code generated by the app.
After the user successfully sets up their authenticator app, they will be redirected to a success page.
Next, we'll create the necessary pages for the authenticator app setup.
To display the QR code, we need to install the react-qr-code
package:
# Using Yarn
yarn add react-qr-code
# Using npm
npm install react-qr-code
Now, create a new file: src/app/auth/app-setup/Setup.tsx
for the QR code setup component.
Here’s the code for the component:
1"use client";
2
3import { useActionState } from "react";
4import { saveTotpSecret } from "../actions";
5import QRCode from "react-qr-code";
6
7const initialState = {
8 message: "",
9};
10
11export default function Setup({
12 secret,
13 url,
14}: {
15 secret: string;
16 url: string;
17}) {
18 const [state, formAction] = useActionState(saveTotpSecret, initialState);
19
20 return (
21 <>
22 <div className="">
23 <div
24 className="flex flex-col items-center px-6 py-8 mt-8
25 mx-auto lg:py-0 w-fit"
26 >
27 <div className="w-full bg-white rounded-lg shadow md:mt-0 sm:max-w-md xl:p-0">
28 <div className="p-6 space-y-2 md:space-y-3 sm:p-8">
29 <h3 className="text-xl font-semibold pb-4">
30 Setup authenticator app
31 </h3>
32
33 <p>
34 Use you a phone app like Microsoft Authenticator, etc, to scan
35 the QR Code, or enter the secret key manually.
36 </p>
37
38 <p className="py-2">
39 Secret key: <strong>{secret}</strong>
40 </p>
41 <div
42 style={{
43 height: "auto",
44 margin: "0 auto",
45 maxWidth: 150,
46 width: "100%",
47 }}
48 >
49 <QRCode
50 size={256}
51 style={{ height: "auto", maxWidth: "100%", width: "100%" }}
52 value={url}
53 viewBox={`0 0 256 256`}
54 />
55 </div>
56
57 <div className="pt-4">
58 <p className="mb-2">
59 Enter the 6 digits code generated by the authenticator app and
60 click "Continue"
61 </p>
62 </div>
63
64 <div>
65 <p className="mb-3">
66 Verify the code from the authenticator app
67 </p>
68 <form action={formAction}>
69 <input type="hidden" name="secret" value={secret} />
70
71 <div>
72 <input
73 type="text"
74 name="code"
75 autoComplete="off"
76 className="bg-gray-50 border border-gray-300
77 text-gray-900 rounded-lg focus:ring-gray-600
78 focus:border-gray-600 block p-2"
79 placeholder="XXXXXX"
80 />
81 </div>
82 {state.message && (
83 <div className="py-2 text-red-500 text-sm">
84 {state.message}
85 </div>
86 )}
87 <div className="pt-4">
88 <button
89 type="submit"
90 className="text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2 text-center"
91 >
92 Continue
93 </button>
94 </div>
95 </form>
96 </div>
97 </div>
98 </div>
99 </div>
100 </div>
101 </>
102 );
103}
Next, let's create a new file src/app/auth/app-setup/page.tsx
for the authenticator app setup page and include the following code:
1import { redirect } from "next/navigation";
2import { generateSecret, getSession } from "../actions";
3import Setup from "./Setup";
4
5export default async function Page() {
6 const session = await getSession();
7
8 if (!session.isLoggedIn) redirect("/auth/login");
9
10 const totpData = await generateSecret();
11
12 return (
13 <>
14 <Setup secret={totpData.secret} url={totpData.url} />
15 </>
16 );
17}
Finally, for the authenticator app setup, let's add the success page src/app/auth/app-setup-success/page.tsx
:
1export default function Page() {
2 return <>Authenticator app setup successful</>;
3}
To set up the authenticator app, users will need to navigate to the dashboard and click the setup link if TOTP is not yet enabled. Currently, the frontend lacks a way to indicate whether TOTP is enabled. Let’s address that by adding the checkTotpStatus
function in the file src/app/auth/actions.ts
:
1export async function checkTotpStatus() {
2 const session = await getSession();
3
4 const res = await fetch(`${API_URL}/auth/totp-enabled`, {
5 headers: {
6 "Content-Type": "application/json",
7 Authorization: `Bearer ${session.jwt}`,
8 },
9 });
10
11 const result: { enabled: boolean } = await res.json();
12
13 return result;
14}
Let us return to the dashboard page located at src/app/dashboard/page.tsx
and make the necessary updates:
1import { redirect } from "next/navigation";
2import { getSession, checkTotpStatus } from "../auth/actions";
3
4export default async function Page() {
5 const session = await getSession();
6
7 if (!session.isLoggedIn) redirect("/");
8
9 const totpEnabled = (await checkTotpStatus())?.enabled ?? false;
10
11 return (
12 <>
13 <section>
14 <h2 className="py-8 text-3xl font-bold text-center">Dashboard</h2>
15 <div className="text-center">
16 {!totpEnabled && (
17 <a className="text-lg font-semibold" href="/auth/app-setup">
18 Setup Authenticator App
19 </a>
20 )}
21
22 {totpEnabled && <p>Authenticator app enabled</p>}
23 </div>
24 </section>
25 </>
26 );
27}
In this tutorial, we successfully implemented 2FA in a Strapi CMS application and integrated a frontend using Next.js. We explored both OTP-based email verification and TOTP-based authenticator app setup to provide users with flexible, secure login options.
We started with backend configurations, extended Strapi’s authentication logic to handle OTP and TOTP validation. On the frontend, we built user-friendly registration, login, and verification flows. We also walked through generating and saving TOTP secrets and used QR codes to simplify setting up an authenticator app.
Emeka is a skilled software developer and educator in web development, mentoring, and collaborating with teams to deliver business-driven solutions.