How to Add Email OTP 2FA to the Strapi Admin Panel

#Strapi#TwoFactorAuthentication#HeadlessCMS#AdminSecurity#WebDevelopment
How to Add Email OTP 2FA to the Strapi Admin Panel

This guide shows how to build a complete email OTP based 2FA flow for the Strapi admin panel. It is written as a direct implementation guide, not just an overview. That means every important file, code block, and setup step is included so you can reproduce the feature in a new Strapi project or add it into an existing one.

The feature works like this:

  • Admin enters email and password
  • Backend validates the credentials
  • Backend generates an OTP and sends it by email
  • Admin sees an OTP screen in the Strapi admin panel
  • Admin verifies the OTP
  • Only then is the real admin session created

By default, Strapi admin login is just email and password. That means if anyone gets those credentials, they can directly access the dashboard. This guide adds an extra verification layer without depending on a paid plugin.


What You Will Build

  • A custom admin login API that validates email and password first
  • A short-lived OTP challenge stored in Strapi
  • An email OTP delivery step using the Strapi email plugin
  • A custom admin OTP verification screen inside the Strapi admin panel
  • A resend OTP flow with rate limits and disabled button states
  • Final admin session creation only after OTP verification succeeds

This is a Strapi v5 guide written from a working TypeScript setup. If your project uses JavaScript instead of TypeScript, the flow is the same, but you should create .js files instead of .ts files for your backend code.


Before You Start

  • Strapi v5 project
  • Admin panel already working
  • At least one admin user already exists and can log in normally
  • Email sending configured in Strapi
  • Node.js and npm already working
  • Comfortable editing project files directly

This guide uses the same structure that was implemented in a real Strapi backend and verified with successful builds.

Important: this feature works by patching internal Strapi admin files during install and build. That means you should re-check the patch paths after every Strapi upgrade, especially when updating major or minor versions.


What Files You Will Create or Update

src/api/admin-otp/routes/admin-otp.ts
src/api/admin-otp/controllers/admin-otp.ts
src/api/admin-otp/services/admin-otp.ts
src/utils/strapi-session-auth.ts
config/plugins.ts
config/server.ts
package.json
scripts/apply-strapi-admin-otp-patch.js
scripts/strapi-admin-otp-patch/services/auth.mjs
scripts/strapi-admin-otp-patch/services/auth.js
scripts/strapi-admin-otp-patch/pages/Auth/components/Login.mjs
scripts/strapi-admin-otp-patch/pages/Auth/components/Login.js

The .mjs and .js patched admin files are both important. Strapi admin can resolve either depending on the runtime path, so patching only one side can cause the old login flow to stay active.


Quick Start Checklist

  1. Create the backend route, controller, service, and session helper files exactly as shown below
  2. Update config/plugins.ts with a working email provider
  3. Update config/server.ts so secure cookies work properly behind a proxy
  4. Create the patched admin auth service files and login component files
  5. Create the patch copier script and wire it into package.json
  6. Run npm install, then npm run build, then npm run develop
  7. Log in with valid admin credentials and confirm that the OTP step appears before the dashboard

If you are adding this to an existing project, back up your current admin customization first. If you are adding it to a new project, confirm normal admin login works before you start patching the OTP flow.


Step 1: Configure Email in Strapi

Update only the email part in:

config/plugins.ts
import type { Core } from "@strapi/strapi";

const config = ({
  env,
}: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
  email: {
    config: {
      provider: "nodemailer",
      providerOptions: {
        host: env("SMTP_HOST"),
        port: env.int("SMTP_PORT", 587),
        pool: true,
        maxConnections: 5,
        maxMessages: 100,
        connectionTimeout: 10000,
        greetingTimeout: 10000,
        socketTimeout: 60000,
        auth: {
          user: env("SMTP_USERNAME"),
          pass: env("SMTP_PASSWORD"),
        },
        secure: env.bool("SMTP_SECURE", false),
      },
      settings: {
        defaultFrom: env("SMTP_FROM_EMAIL"),
        defaultReplyTo: env("SMTP_FROM_EMAIL"),
      },
    },
  },
});

export default config;

If your config/plugins.ts file already contains unrelated providers like upload or search, keep them in your project. They are not part of the 2FA implementation, so they are intentionally not shown in this guide.


Step 2: Add Environment Variables

HOST=0.0.0.0
PORT=1337
URL=https://your-domain.com
APP_KEYS=yourAppKey1,yourAppKey2
API_TOKEN_SALT=yourApiTokenSalt
ADMIN_JWT_SECRET=yourAdminJwtSecret
TRANSFER_TOKEN_SALT=yourTransferTokenSalt
JWT_SECRET=yourJwtSecret
ENCRYPTION_KEY=yourEncryptionKey
IS_PROXIED=true
ADMIN_OTP_TTL_SECONDS=300
ADMIN_OTP_RATE_LIMIT_WINDOW_SECONDS=900
ADMIN_OTP_LOGIN_IP_LIMIT=10
ADMIN_OTP_LOGIN_EMAIL_LIMIT=5
ADMIN_OTP_VERIFY_IP_LIMIT=20
ADMIN_OTP_VERIFY_EMAIL_LIMIT=10
ADMIN_OTP_RESEND_IP_LIMIT=10
ADMIN_OTP_RESEND_EMAIL_LIMIT=5
ADMIN_OTP_DEBUG_TIMINGS=false
SMTP_HOST=smtp.your-provider.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password-or-app-password
SMTP_FROM_EMAIL=no-reply@your-domain.com

These values harden the OTP flow by shortening the code lifetime, adding rate limits for login, verify, and resend, and disabling detailed timing logs in production by default.

Never commit real secrets to your repository. Use your deployment platform or a local .env file that is ignored by git, and prefer SMTP app passwords or provider-issued credentials instead of your main mailbox password.


Step 3: Configure Server Proxy Support

Update:

config/server.ts
import type { Core } from '@strapi/strapi';

const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Server => ({
  host: env('HOST', '0.0.0.0'),
  port: env.int('PORT', 1337),
  url: env("URL", "http://localhost:1337"),
  proxy: env.bool("IS_PROXIED", env("NODE_ENV", "development") === "production"),
  app: {
    keys: env.array('APP_KEYS'),
  },
});

export default config;

This lets Strapi trust proxy headers and correctly detect HTTPS in production. If you deploy behind Coolify, Traefik, Nginx, or another reverse proxy, keep IS_PROXIED=true.


Step 4: Create OTP Routes

Create:

src/api/admin-otp/routes/admin-otp.ts
export default {
  routes: [
    {
      method: "POST",
      path: "/admin-otp/login",
      handler: "admin-otp.login",
      config: {
        auth: false,
      },
    },
    {
      method: "POST",
      path: "/admin-otp/verify",
      handler: "admin-otp.verify",
      config: {
        auth: false,
      },
    },
    {
      method: "POST",
      path: "/admin-otp/resend",
      handler: "admin-otp.resend",
      config: {
        auth: false,
      },
    },
  ],
};

Step 5: Create the OTP Controller

Create:

src/api/admin-otp/controllers/admin-otp.ts
import type { Core } from "@strapi/strapi";

import sessionAuth = require("../../../utils/strapi-session-auth");

const getService = (strapi: Core.Strapi) =>
  strapi.service("api::admin-otp.admin-otp");

const setRefreshCookie = (ctx: any, refreshToken: string, cookieOptions: Record<string, unknown>) => {
  ctx.cookies.set(sessionAuth.REFRESH_COOKIE_NAME, refreshToken, cookieOptions);
};

const getClientIp = (ctx: any) => {
  const forwardedFor = ctx.request.headers["x-forwarded-for"];

  if (typeof forwardedFor === "string" && forwardedFor.trim().length > 0) {
    return forwardedFor.split(",")[0].trim();
  }

  if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
    return String(forwardedFor[0]).trim();
  }

  return String(ctx.request.ip ?? ctx.ip ?? "").trim();
};

export default {
  async login(ctx: any) {
    const service = getService(strapi as Core.Strapi);
    const result = await service.createChallenge(ctx.request.body ?? {}, {
      clientIp: getClientIp(ctx),
    });

    ctx.body = { data: result };
  },

  async resend(ctx: any) {
    const service = getService(strapi as Core.Strapi);
    const result = await service.resendChallenge(ctx.request.body ?? {}, {
      clientIp: getClientIp(ctx),
    });

    ctx.body = { data: result };
  },

  async verify(ctx: any) {
    const service = getService(strapi as Core.Strapi);
    const result = await service.verifyChallenge(ctx.request.body ?? {}, {
      secureRequest: ctx.request.secure,
      clientIp: getClientIp(ctx),
    });

    setRefreshCookie(ctx, result.refreshToken, result.cookieOptions);

    ctx.body = {
      data: {
        token: result.accessToken,
        accessToken: result.accessToken,
        user: result.user,
      },
    };
  },
};

Step 6: Create the Strapi Session Helper

Create:

src/utils/strapi-session-auth.ts
import path from "path";

const sessionAuthPath = path.join(
  process.cwd(),
  "node_modules",
  "@strapi",
  "admin",
  "dist",
  "server",
  "shared",
  "utils",
  "session-auth.js"
);

const sessionAuth = require(sessionAuthPath);

export = sessionAuth;

Step 7: Create the OTP Service

Create:

The code below shows the main OTP service structure. For a production-ready setup, combine it with the hardening notes that appear right after this section, especially the shorter TTL, IP and email based rate limiting, and reduced production logging.

src/api/admin-otp/services/admin-otp.ts
import crypto from "crypto";
import type { Core } from "@strapi/strapi";
import { errors } from "@strapi/utils";
import sessionAuth = require("../../../utils/strapi-session-auth");

const { ApplicationError, ValidationError } = errors;

const STORE_NAME = "admin-otp-login";
const STORE_KEY_PREFIX = "challenge:";
const OTP_DIGITS = 6;
const OTP_TTL_SECONDS = 5 * 60;
const MAX_ATTEMPTS = 5;
const MAX_RESENDS = 3;

type AdminOtpChallenge = {
  id: string;
  userId: number;
  email: string;
  deviceId: string;
  rememberMe: boolean;
  salt: string;
  hash: string;
  attempts: number;
  resendCount: number;
  expiresAt: string;
};

type SessionResult = {
  refreshToken: string;
  cookieOptions: Record<string, unknown>;
  accessToken: string;
  user: unknown;
};

type StrapiStore = ReturnType<Core.Strapi["store"]>;

const now = () => Date.now();

const logDuration = (strapi: Core.Strapi, label: string, startedAt: number, meta?: Record<string, unknown>) => {
  const durationMs = Date.now() - startedAt;
  const suffix = meta ? ` ${JSON.stringify(meta)}` : "";
  strapi.log.info(`[admin-otp] ${label} completed in ${durationMs}ms${suffix}`);
};

const normalizeEmail = (email: unknown) => {
  if (typeof email !== "string") {
    return "";
  }

  return email.trim().toLowerCase();
};

const ensureString = (value: unknown, message: string) => {
  if (typeof value !== "string" || value.trim().length === 0) {
    throw new ValidationError(message);
  }

  return value.trim();
};

const ensureOtpFormat = (value: unknown) => {
  const code = ensureString(value, "OTP code is required");

  if (!/^\\d{6}$/.test(code)) {
    throw new ValidationError("OTP code must be a 6-digit number");
  }

  return code;
};

const createOtpCode = () =>
  crypto.randomInt(0, 10 ** OTP_DIGITS).toString().padStart(OTP_DIGITS, "0");

const createOtpHash = (challengeId: string, code: string, salt: string) =>
  new Promise<string>((resolve, reject) => {
    crypto.scrypt(`${challengeId}:${code}`, salt, 64, (error, derivedKey) => {
      if (error) {
        reject(error);
        return;
      }

      resolve(derivedKey.toString("hex"));
    });
  });

const getStore = (strapi: Core.Strapi) =>
  strapi.store({
    type: "plugin",
    name: STORE_NAME,
  });

const getStoreKey = (challengeId: string) => `${STORE_KEY_PREFIX}${challengeId}`;

const getExpirySeconds = () => {
  const raw = Number(process.env.ADMIN_OTP_TTL_SECONDS ?? OTP_TTL_SECONDS);

  if (!Number.isFinite(raw) || raw <= 0) {
    return OTP_TTL_SECONDS;
  }

  return Math.floor(raw);
};

const deleteChallenge = async (store: StrapiStore, challengeId: string) => {
  await store.delete({ key: getStoreKey(challengeId) });
};

const getChallenge = async (store: StrapiStore, challengeId: string) => {
  const challenge = (await store.get({
    key: getStoreKey(challengeId),
  })) as AdminOtpChallenge | null;

  if (!challenge) {
    throw new ApplicationError("OTP session not found. Please log in again.");
  }

  if (new Date(challenge.expiresAt).getTime() <= Date.now()) {
    await deleteChallenge(store, challengeId);
    throw new ApplicationError("OTP expired. Please log in again.");
  }

  return challenge;
};

const sendOtpEmail = async (strapi: Core.Strapi, email: string, code: string) => {
  const startedAt = now();
  await strapi.plugin("email").service("email").send({
    to: email,
    subject: "Your admin login OTP code",
    text: `Your OTP code is ${code}. It expires in ${Math.floor(getExpirySeconds() / 60)} minutes.`,
    html: `
      <p>Your admin login OTP code is <strong>${code}</strong>.</p>
      <p>This code expires in ${Math.floor(getExpirySeconds() / 60)} minutes.</p>
      <p>If you did not try to sign in, please change your password immediately.</p>
    `,
  });
  logDuration(strapi, "sendOtpEmail", startedAt, { email });
};

const createSession = async (
  strapi: Core.Strapi,
  userId: number,
  deviceId: string,
  rememberMe: boolean,
  secureRequest: boolean
): Promise<SessionResult> => {
  const sessionManager = sessionAuth.getSessionManager();

  if (!sessionManager) {
    throw new ApplicationError("Admin session manager is not available");
  }

  const { token: refreshToken, absoluteExpiresAt } = await sessionManager("admin").generateRefreshToken(
    String(userId),
    deviceId,
    {
      type: rememberMe ? "refresh" : "session",
    }
  );

  const cookieOptions = sessionAuth.buildCookieOptionsWithExpiry(
    rememberMe ? "refresh" : "session",
    absoluteExpiresAt,
    secureRequest
  );

  const accessResult = await sessionManager("admin").generateAccessToken(refreshToken);

  if ("error" in accessResult) {
    throw new ApplicationError("Failed to generate admin access token");
  }

  const userService = strapi.service("admin::user");
  const user = await strapi.db.query("admin::user").findOne({
    where: { id: userId },
  });

  if (!user) {
    throw new ApplicationError("Admin user no longer exists");
  }

  return {
    refreshToken,
    cookieOptions,
    accessToken: accessResult.token,
    user: userService.sanitizeUser(user),
  };
};
export default ({ strapi }: { strapi: Core.Strapi }) => ({
  async createChallenge(body: Record<string, unknown>) {
    const requestStartedAt = now();
    const email = normalizeEmail(body.email);
    const password = ensureString(body.password, "Password is required");
    const deviceId =
      typeof body.deviceId === "string" && body.deviceId.trim().length > 0
        ? body.deviceId.trim()
        : sessionAuth.generateDeviceId();
    const rememberMe = Boolean(body.rememberMe);

    if (!email) {
      throw new ValidationError("Email is required");
    }

    const credentialsStartedAt = now();
    const [, user, info] = (await strapi.service("admin::auth").checkCredentials({
      email,
      password,
    })) as [null, { id: number; email: string } | false, { message?: string }?];
    logDuration(strapi, "checkCredentials", credentialsStartedAt, { email });

    if (!user) {
      throw new ApplicationError(info?.message ?? "Invalid credentials");
    }

    const challengeId = crypto.randomUUID();
    const code = createOtpCode();
    const salt = crypto.randomBytes(16).toString("hex");
    const expiresAt = new Date(Date.now() + getExpirySeconds() * 1000).toISOString();
    const store = getStore(strapi);
    const hashStartedAt = now();
    const hash = await createOtpHash(challengeId, code, salt);
    logDuration(strapi, "createOtpHash", hashStartedAt, { email });

    const challenge: AdminOtpChallenge = {
      id: challengeId,
      userId: user.id,
      email,
      deviceId,
      rememberMe,
      salt,
      hash,
      attempts: 0,
      resendCount: 0,
      expiresAt,
    };

    const storeStartedAt = now();
    await store.set({
      key: getStoreKey(challengeId),
      value: challenge,
    });
    logDuration(strapi, "storeChallenge", storeStartedAt, { email, challengeId });

    await sendOtpEmail(strapi, email, code);
    logDuration(strapi, "createChallenge", requestStartedAt, { email, challengeId });

    return {
      challengeId,
      expiresAt,
      maskedEmail: email,
      rememberMe,
    };
  },

  async resendChallenge(body: Record<string, unknown>) {
    const requestStartedAt = now();
    const challengeId = ensureString(body.challengeId, "Challenge ID is required");
    const store = getStore(strapi);
    const loadStartedAt = now();
    const current = await getChallenge(store, challengeId);
    logDuration(strapi, "loadChallengeForResend", loadStartedAt, { email: current.email, challengeId });

    if (current.resendCount >= MAX_RESENDS) {
      await deleteChallenge(store, challengeId);
      throw new ApplicationError("Maximum OTP resend attempts exceeded. Please log in again.");
    }

    const code = createOtpCode();
    const salt = crypto.randomBytes(16).toString("hex");
    const hashStartedAt = now();
    const hash = await createOtpHash(challengeId, code, salt);
    logDuration(strapi, "createOtpHashForResend", hashStartedAt, { email: current.email, challengeId });
    const nextChallenge: AdminOtpChallenge = {
      ...current,
      salt,
      hash,
      resendCount: current.resendCount + 1,
      attempts: 0,
      expiresAt: new Date(Date.now() + getExpirySeconds() * 1000).toISOString(),
    };

    const storeStartedAt = now();
    await store.set({
      key: getStoreKey(challengeId),
      value: nextChallenge,
    });
    logDuration(strapi, "storeResentChallenge", storeStartedAt, { email: current.email, challengeId });

    await sendOtpEmail(strapi, current.email, code);
    logDuration(strapi, "resendChallenge", requestStartedAt, { email: current.email, challengeId });

    return {
      challengeId,
      expiresAt: nextChallenge.expiresAt,
      maskedEmail: current.email,
    };
  },

  async verifyChallenge(
    body: Record<string, unknown>,
    options: { secureRequest: boolean }
  ) {
    const requestStartedAt = now();
    const challengeId = ensureString(body.challengeId, "Challenge ID is required");
    const code = ensureOtpFormat(body.code);
    const store = getStore(strapi);
    const loadStartedAt = now();
    const challenge = await getChallenge(store, challengeId);
    logDuration(strapi, "loadChallengeForVerify", loadStartedAt, { email: challenge.email, challengeId });

    if (challenge.attempts >= MAX_ATTEMPTS) {
      await deleteChallenge(store, challengeId);
      throw new ApplicationError("Maximum OTP attempts exceeded. Please log in again.");
    }

    const hashStartedAt = now();
    const computedHash = await createOtpHash(challengeId, code, challenge.salt);
    logDuration(strapi, "createOtpHashForVerify", hashStartedAt, { email: challenge.email, challengeId });
    const isValid = crypto.timingSafeEqual(
      Buffer.from(computedHash, "hex"),
      Buffer.from(challenge.hash, "hex")
    );

    if (!isValid) {
      const nextAttempts = challenge.attempts + 1;

      if (nextAttempts >= MAX_ATTEMPTS) {
        await deleteChallenge(store, challengeId);
        throw new ApplicationError("Maximum OTP attempts exceeded. Please log in again.");
      }

      const storeStartedAt = now();
      await store.set({
        key: getStoreKey(challengeId),
        value: {
          ...challenge,
          attempts: nextAttempts,
        },
      });
      logDuration(strapi, "storeFailedAttempt", storeStartedAt, { email: challenge.email, challengeId, attempts: nextAttempts });

      throw new ApplicationError("Invalid OTP code");
    }

    const deleteStartedAt = now();
    await deleteChallenge(store, challengeId);
    logDuration(strapi, "deleteChallengeAfterVerify", deleteStartedAt, { email: challenge.email, challengeId });

    const sessionStartedAt = now();
    const session = await createSession(
      strapi,
      challenge.userId,
      challenge.deviceId,
      challenge.rememberMe,
      options.secureRequest
    );
    logDuration(strapi, "createSession", sessionStartedAt, { email: challenge.email, challengeId });
    logDuration(strapi, "verifyChallenge", requestStartedAt, { email: challenge.email, challengeId });
    return session;
  },
});

Important Hardening Notes

  • The hardened production version uses a default OTP TTL of 300 seconds, not 600 seconds.
  • The controller should pass the client IP into createChallenge, resendChallenge, and verifyChallenge so IP based rate limits can work.
  • The production-ready service also adds IP and email based rate limiting for login, verify, and resend actions.
  • Detailed timing logs should be disabled in production unless you intentionally enable ADMIN_OTP_DEBUG_TIMINGS=true for debugging.
  • If you are implementing this guide, make sure your final src/api/admin-otp/services/admin-otp.ts and src/api/admin-otp/controllers/admin-otp.ts reflect those hardening changes.

In other words, treat the environment variable section and the proxy support section above as part of the final secure implementation, not as optional extras.


Step 8: Patch the Strapi Admin Auth Service

Create:

scripts/strapi-admin-otp-patch/services/auth.mjs
import { adminApi } from './api.mjs';

const authService = adminApi.enhanceEndpoints({
    addTagTypes: ['User', 'Me', 'ProvidersOptions']
}).injectEndpoints({
    endpoints: (builder)=>({
        login: builder.mutation({
            query: (body)=>({
                method: 'POST',
                url: '/admin/login',
                data: body
            }),
            transformResponse (res) {
                return res.data;
            },
            invalidatesTags: ['Me']
        }),
        adminLoginWithOtp: builder.mutation({
            query: (body)=>({
                method: 'POST',
                url: '/api/admin-otp/login',
                data: body
            }),
            transformResponse (res) {
                return res.data;
            }
        }),
        verifyAdminLoginOtp: builder.mutation({
            query: (body)=>({
                method: 'POST',
                url: '/api/admin-otp/verify',
                data: body
            }),
            transformResponse (res) {
                return res.data;
            },
            invalidatesTags: ['Me']
        }),
        resendAdminLoginOtp: builder.mutation({
            query: (body)=>({
                method: 'POST',
                url: '/api/admin-otp/resend',
                data: body
            }),
            transformResponse (res) {
                return res.data;
            }
        })
    }),
    overrideExisting: true
});

const {
    useAdminLoginWithOtpMutation,
    useVerifyAdminLoginOtpMutation,
    useResendAdminLoginOtpMutation
} = authService;

export {
    useAdminLoginWithOtpMutation,
    useVerifyAdminLoginOtpMutation,
    useResendAdminLoginOtpMutation
};

Then create the CommonJS mirror too:

scripts/strapi-admin-otp-patch/services/auth.js
'use strict';

var api = require('./api.js');

const authService = api.adminApi.enhanceEndpoints({
    addTagTypes: [
        'User',
        'Me',
        'ProvidersOptions'
    ]
}).injectEndpoints({
    endpoints: (builder)=>({
            getMe: builder.query({
                query: ()=>({
                        method: 'GET',
                        url: '/admin/users/me'
                    }),
                transformResponse (res) {
                    return res.data;
                },
                providesTags: (res)=>res ? [
                        'Me',
                        {
                            type: 'User',
                            id: res.id
                        }
                    ] : [
                        'Me'
                    ]
            }),
            getMyPermissions: builder.query({
                query: ()=>({
                        method: 'GET',
                        url: '/admin/users/me/permissions'
                    }),
                transformResponse (res) {
                    return res.data;
                }
            }),
            updateMe: builder.mutation({
                query: (body)=>({
                        method: 'PUT',
                        url: '/admin/users/me',
                        data: body
                    }),
                transformResponse (res) {
                    return res.data;
                },
                invalidatesTags: ['Me']
            }),
            checkPermissions: builder.query({
                query: (permissions)=>({
                        method: 'POST',
                        url: '/admin/permissions/check',
                        data: permissions
                    })
            }),
            login: builder.mutation({
                query: (body)=>({
                        method: 'POST',
                        url: '/admin/login',
                        data: body
                    }),
                transformResponse (res) {
                    return res.data;
                },
                invalidatesTags: ['Me']
            }),
            adminLoginWithOtp: builder.mutation({
                query: (body)=>({
                        method: 'POST',
                        url: '/api/admin-otp/login',
                        data: body
                    }),
                transformResponse (res) {
                    return res.data;
                }
            }),
            verifyAdminLoginOtp: builder.mutation({
                query: (body)=>({
                        method: 'POST',
                        url: '/api/admin-otp/verify',
                        data: body
                    }),
                transformResponse (res) {
                    return res.data;
                },
                invalidatesTags: ['Me']
            }),
            resendAdminLoginOtp: builder.mutation({
                query: (body)=>({
                        method: 'POST',
                        url: '/api/admin-otp/resend',
                        data: body
                    }),
                transformResponse (res) {
                    return res.data;
                }
            }),
            logout: builder.mutation({
                query: (body)=>({
                        method: 'POST',
                        url: '/admin/logout',
                        data: body
                    })
            }),
            resetPassword: builder.mutation({
                query: (body)=>({
                        method: 'POST',
                        url: '/admin/reset-password',
                        data: body
                    }),
                transformResponse (res) {
                    return res.data;
                }
            }),
            accessTokenExchange: builder.mutation({
                query: (body)=>({
                        method: 'POST',
                        url: '/admin/access-token',
                        data: body
                    }),
                transformResponse (res) {
                    return res.data;
                }
            }),
            getRegistrationInfo: builder.query({
                query: (registrationToken)=>({
                        url: '/admin/registration-info',
                        method: 'GET',
                        config: {
                            params: {
                                registrationToken
                            }
                        }
                    }),
                transformResponse (res) {
                    return res.data;
                }
            }),
            registerAdmin: builder.mutation({
                query: (body)=>({
                        method: 'POST',
                        url: '/admin/register-admin',
                        data: body
                    }),
                transformResponse (res) {
                    return res.data;
                }
            }),
            registerUser: builder.mutation({
                query: (body)=>({
                        method: 'POST',
                        url: '/admin/register',
                        data: body
                    }),
                transformResponse (res) {
                    return res.data;
                }
            }),
            forgotPassword: builder.mutation({
                query: (body)=>({
                        url: '/admin/forgot-password',
                        method: 'POST',
                        data: body
                    })
            }),
            isSSOLocked: builder.query({
                query: ()=>({
                        url: '/admin/providers/isSSOLocked',
                        method: 'GET'
                    }),
                transformResponse (res) {
                    return res.data;
                }
            }),
            getProviders: builder.query({
                query: ()=>({
                        url: '/admin/providers',
                        method: 'GET'
                    })
            }),
            getProviderOptions: builder.query({
                query: ()=>({
                        url: '/admin/providers/options',
                        method: 'GET'
                    }),
                transformResponse (res) {
                    return res.data;
                },
                providesTags: ['ProvidersOptions']
            }),
            updateProviderOptions: builder.mutation({
                query: (body)=>({
                        url: '/admin/providers/options',
                        method: 'PUT',
                        data: body
                    }),
                transformResponse (res) {
                    return res.data;
                },
                invalidatesTags: ['ProvidersOptions']
            })
        }),
    overrideExisting: true
});

const { useCheckPermissionsQuery, useLazyCheckPermissionsQuery, useGetMeQuery, useLoginMutation, useAdminLoginWithOtpMutation, useVerifyAdminLoginOtpMutation, useResendAdminLoginOtpMutation, useAccessTokenExchangeMutation, useLogoutMutation, useUpdateMeMutation, useResetPasswordMutation, useRegisterAdminMutation, useRegisterUserMutation, useGetRegistrationInfoQuery, useForgotPasswordMutation, useGetMyPermissionsQuery, useIsSSOLockedQuery, useGetProvidersQuery, useGetProviderOptionsQuery, useUpdateProviderOptionsMutation } = authService;

exports.useAccessTokenExchangeMutation = useAccessTokenExchangeMutation;
exports.useAdminLoginWithOtpMutation = useAdminLoginWithOtpMutation;
exports.useCheckPermissionsQuery = useCheckPermissionsQuery;
exports.useForgotPasswordMutation = useForgotPasswordMutation;
exports.useGetMeQuery = useGetMeQuery;
exports.useGetMyPermissionsQuery = useGetMyPermissionsQuery;
exports.useGetProviderOptionsQuery = useGetProviderOptionsQuery;
exports.useGetProvidersQuery = useGetProvidersQuery;
exports.useGetRegistrationInfoQuery = useGetRegistrationInfoQuery;
exports.useIsSSOLockedQuery = useIsSSOLockedQuery;
exports.useLazyCheckPermissionsQuery = useLazyCheckPermissionsQuery;
exports.useLoginMutation = useLoginMutation;
exports.useLogoutMutation = useLogoutMutation;
exports.useResendAdminLoginOtpMutation = useResendAdminLoginOtpMutation;
exports.useRegisterAdminMutation = useRegisterAdminMutation;
exports.useRegisterUserMutation = useRegisterUserMutation;
exports.useResetPasswordMutation = useResetPasswordMutation;
exports.useUpdateMeMutation = useUpdateMeMutation;
exports.useUpdateProviderOptionsMutation = useUpdateProviderOptionsMutation;
exports.useVerifyAdminLoginOtpMutation = useVerifyAdminLoginOtpMutation;

Step 9: Patch the Strapi Admin Login Screen

Create:

scripts/strapi-admin-otp-patch/pages/Auth/components/Login.mjs
import { jsx, jsxs } from 'react/jsx-runtime';
import * as React from 'react';
import { Main, Box, Typography, Flex, Button, Link } from '@strapi/design-system';
import camelCase from 'lodash/camelCase';
import { useIntl } from 'react-intl';
import { useLocation, useNavigate, NavLink } from 'react-router-dom';
import * as yup from 'yup';
import { Form, useForm } from '../../../components/Form.mjs';
import { InputRenderer as MemoizedInputRenderer } from '../../../components/FormInputs/Renderer.mjs';
import { Logo } from '../../../components/UnauthenticatedLogo.mjs';
import { UnauthenticatedLayout, LayoutContent, Column } from '../../../layouts/UnauthenticatedLayout.mjs';
import { useTypedDispatch } from '../../../core/store/hooks.mjs';
import { useNotification } from '../../../features/Notifications.mjs';
import { login as loginAction } from '../../../reducer.mjs';
import { useAdminLoginWithOtpMutation, useVerifyAdminLoginOtpMutation, useResendAdminLoginOtpMutation } from '../../../services/auth.mjs';
import { getOrCreateDeviceId } from '../../../utils/deviceId.mjs';
import { translatedErrors as errorsTrads } from '../../../utils/translatedErrors.mjs';

const OTP_LENGTH = 6;
const OTP_DIGIT_INPUT_STYLE = {
    width: '3.75rem',
    height: '4.5rem',
    borderRadius: '1rem',
    borderStyle: 'solid',
    borderWidth: '2px',
    borderColor: 'var(--strapi-colors-neutral500)',
    backgroundColor: 'var(--strapi-colors-neutral200)',
    color: 'var(--strapi-colors-neutral800)',
    fontSize: '1.85rem',
    fontWeight: 700,
    textAlign: 'center',
    outline: 'none',
    transition: 'all 160ms ease',
    boxShadow: 'none'
};

const sanitizeOtp = (value = '')=>value.replace(/\\D/g, '').slice(0, OTP_LENGTH);
const createOtpDigits = (value = '')=>Array.from({ length: OTP_LENGTH }, (_, index)=>value[index] ?? '');

const LOGIN_SCHEMA = yup.object().shape({
    email: yup.string().nullable().email({
        id: errorsTrads.email.id,
        defaultMessage: 'Not a valid email'
    }).required(errorsTrads.required),
    password: yup.string().required(errorsTrads.required).nullable(),
    rememberMe: yup.bool().nullable()
});

const OTP_SCHEMA = yup.object().shape({
    code: yup.string().nullable().matches(/^\\d{6}$/, {
        message: 'OTP code must be a 6-digit number'
    }).required(errorsTrads.required)
});

const OtpField = ()=>{
    const { values, errors, onChange, isSubmitting } = useForm('OtpField', (state)=>state);
    const inputRefs = React.useRef([]);
    const codeValue = typeof values.code === 'string' ? values.code : '';
    const digits = React.useMemo(()=>createOtpDigits(codeValue), [codeValue]);
    const [activeIndex, setActiveIndex] = React.useState(0);

    const focusInput = React.useCallback((index)=>{
        window.requestAnimationFrame(()=>{
            const element = inputRefs.current[index];
            if (element) {
                element.focus();
                element.select();
            }
        });
    }, []);

    React.useEffect(()=>{
        if (!codeValue) {
            focusInput(0);
        }
    }, [codeValue, focusInput]);

    const commitDigits = React.useCallback((nextDigits, focusIndex)=>{
        const nextCode = nextDigits.join('');
        onChange('code', nextCode || null);
        if (typeof focusIndex === 'number') {
            focusInput(focusIndex);
        }
    }, [focusInput, onChange]);

    const handleChange = (index)=>(event)=>{
        const incomingValue = sanitizeOtp(event.target.value);
        const nextDigits = [...digits];
        if (!incomingValue) {
            nextDigits[index] = '';
            commitDigits(nextDigits, index);
            return;
        }
        incomingValue.split('').forEach((digit, offset)=>{
            const targetIndex = index + offset;
            if (targetIndex < OTP_LENGTH) {
                nextDigits[targetIndex] = digit;
            }
        });
        const nextFocusIndex = Math.min(index + incomingValue.length, OTP_LENGTH - 1);
        commitDigits(nextDigits, nextFocusIndex);
    };

    const handleKeyDown = (index)=>(event)=>{
        if (event.key === 'Backspace') {
            event.preventDefault();
            const nextDigits = [...digits];
            if (nextDigits[index]) {
                nextDigits[index] = '';
                commitDigits(nextDigits, index);
            } else if (index > 0) {
                nextDigits[index - 1] = '';
                commitDigits(nextDigits, index - 1);
            }
            return;
        }
        if (event.key === 'ArrowLeft' && index > 0) {
            event.preventDefault();
            focusInput(index - 1);
            return;
        }
        if (event.key === 'ArrowRight' && index < OTP_LENGTH - 1) {
            event.preventDefault();
            focusInput(index + 1);
        }
    };

    const handlePaste = (event)=>{
        const pastedValue = sanitizeOtp(event.clipboardData.getData('text'));
        if (!pastedValue) {
            return;
        }
        event.preventDefault();
        const nextDigits = createOtpDigits();
        pastedValue.split('').forEach((digit, index)=>{
            nextDigits[index] = digit;
        });
        commitDigits(nextDigits, Math.min(pastedValue.length, OTP_LENGTH) - 1);
    };

    return /*#__PURE__*/ jsxs(Box, {
        padding: 5,
        style: {
            borderRadius: '1.25rem',
            background: 'var(--strapi-colors-neutral150)',
            border: '1px solid var(--strapi-colors-neutral300)'
        },
        children: [
            /*#__PURE__*/ jsx(Box, {
                paddingBottom: 2,
                children: /*#__PURE__*/ jsx(Flex, {
                    gap: 3,
                    justifyContent: 'center',
                    wrap: 'wrap',
                    onPaste: handlePaste,
                    children: digits.map((digit, index)=>/*#__PURE__*/ jsx('input', {
                        'aria-invalid': errors.code ? 'true' : 'false',
                        'aria-label': `OTP digit ${index + 1}`,
                        autoComplete: index === 0 ? 'one-time-code' : 'off',
                        disabled: isSubmitting,
                        inputMode: 'numeric',
                        maxLength: 6,
                        onChange: handleChange(index),
                        onFocus: ()=>setActiveIndex(index),
                        onKeyDown: handleKeyDown(index),
                        pattern: '[0-9]*',
                        ref: (element)=>{
                            inputRefs.current[index] = element;
                        },
                        style: {
                            ...OTP_DIGIT_INPUT_STYLE,
                            borderColor: errors.code ? 'var(--strapi-colors-danger600)' : activeIndex === index ? 'var(--strapi-colors-primary600)' : 'var(--strapi-colors-neutral500)',
                            backgroundColor: activeIndex === index ? 'var(--strapi-colors-neutral0)' : 'var(--strapi-colors-neutral200)',
                            boxShadow: errors.code ? '0 0 0 1px var(--strapi-colors-danger600)' : activeIndex === index ? '0 0 0 3px rgba(73, 69, 255, 0.18)' : 'inset 0 0 0 1px var(--strapi-colors-neutral600)'
                        },
                        type: 'text',
                        value: digit
                    }, index))
                })
            }),
            errors.code ? /*#__PURE__*/ jsx(Box, {
                paddingTop: 3,
                children: /*#__PURE__*/ jsx(Typography, {
                    id: 'otp-code-error',
                    variant: 'pi',
                    textColor: 'danger600',
                    children: errors.code
                })
            }) : null
        ]
    });
};
const Login = ({ children })=>{
    const [apiError, setApiError] = React.useState();
    const [otpStep, setOtpStep] = React.useState(null);
    const { formatMessage } = useIntl();
    const { search: searchString } = useLocation();
    const query = React.useMemo(()=>new URLSearchParams(searchString), [searchString]);
    const navigate = useNavigate();
    const dispatch = useTypedDispatch();
    const { toggleNotification } = useNotification();
    const [adminLoginWithOtp, { isLoading: isLoggingIn }] = useAdminLoginWithOtpMutation();
    const [verifyAdminLoginOtp] = useVerifyAdminLoginOtpMutation();
    const [resendAdminLoginOtp, { isLoading: isResendingOtp }] = useResendAdminLoginOtpMutation();

    React.useEffect(()=>{
        document.title = 'Admin Dashboard';
    }, []);

    const handleLogin = async (body)=>{
        setApiError(undefined);
        const res = await adminLoginWithOtp({
            ...body,
            deviceId: getOrCreateDeviceId()
        });
        if ('error' in res) {
            const message = res.error.message ?? 'Something went wrong';
            if (camelCase(message).toLowerCase() === 'usernotactive') {
                navigate('/auth/oops');
                return;
            }
            setApiError(message);
        } else {
            setOtpStep({
                challengeId: res.data.challengeId,
                expiresAt: res.data.expiresAt,
                maskedEmail: res.data.maskedEmail,
                rememberMe: body.rememberMe
            });
        }
    };

    const handleVerifyOtp = async ({ code })=>{
        if (!otpStep) {
            return;
        }
        setApiError(undefined);
        const res = await verifyAdminLoginOtp({
            challengeId: otpStep.challengeId,
            code
        });
        if ('error' in res) {
            setApiError(res.error.message ?? 'Something went wrong');
        } else {
            dispatch(loginAction({
                token: res.data.token,
                persist: otpStep.rememberMe
            }));
            const redirectTo = query.get('redirectTo');
            const redirectUrl = redirectTo ? decodeURIComponent(redirectTo) : '/';
            navigate(redirectUrl);
        }
    };

    const handleResendOtp = async ()=>{
        if (!otpStep) {
            return;
        }
        setApiError(undefined);
        const res = await resendAdminLoginOtp({
            challengeId: otpStep.challengeId
        });
        if ('error' in res) {
            setApiError(res.error.message ?? 'Something went wrong');
        } else {
            setOtpStep({
                ...otpStep,
                expiresAt: res.data.expiresAt,
                maskedEmail: res.data.maskedEmail
            });
            toggleNotification({
                type: 'success',
                title: formatMessage({
                    id: 'Auth.notification.otpResent.title',
                    defaultMessage: 'OTP resent'
                }),
                message: formatMessage({
                    id: 'Auth.notification.otpResent.message',
                    defaultMessage: `A new OTP has been sent to ${res.data.maskedEmail}.`
                })
            });
        }
    };

    return /*#__PURE__*/ jsx(UnauthenticatedLayout, {
        children: /*#__PURE__*/ jsxs(Main, {
            children: [
                /*#__PURE__*/ jsxs(LayoutContent, {
                    children: [
                        /*#__PURE__*/ jsxs(Column, {
                            children: [
                                /*#__PURE__*/ jsx(Logo, {}),
                                /*#__PURE__*/ jsx(Box, {
                                    paddingTop: 6,
                                    paddingBottom: 1,
                                    children: /*#__PURE__*/ jsx(Typography, {
                                        variant: 'alpha',
                                        tag: 'h1',
                                        textAlign: 'center',
                                        children: formatMessage({
                                            id: otpStep ? 'Auth.form.otp.title' : 'Auth.form.welcome.title',
                                            defaultMessage: otpStep ? 'Enter your OTP code' : 'Welcome!'
                                        })
                                    })
                                }),
                                /*#__PURE__*/ jsx(Box, {
                                    paddingBottom: otpStep ? 5 : 7,
                                    children: /*#__PURE__*/ jsx(Typography, {
                                        variant: 'epsilon',
                                        textColor: 'neutral600',
                                        textAlign: 'center',
                                        display: 'block',
                                        children: formatMessage({
                                            id: otpStep ? 'Auth.form.otp.subtitle' : 'Auth.form.welcome.subtitle',
                                            defaultMessage: otpStep ? `We sent a 6-digit code to ${otpStep.maskedEmail}` : 'Log in to your Strapi account'
                                        })
                                    })
                                }),
                                otpStep ? /*#__PURE__*/ jsx(Box, {
                                    paddingBottom: 5,
                                    children: /*#__PURE__*/ jsx(Typography, {
                                        variant: 'pi',
                                        textColor: 'neutral600',
                                        textAlign: 'center',
                                        children: `OTP expires at ${new Date(otpStep.expiresAt).toLocaleTimeString()}`
                                    })
                                }) : null,
                                apiError ? /*#__PURE__*/ jsx(Box, {
                                    paddingBottom: 4,
                                    children: /*#__PURE__*/ jsx(Typography, {
                                        id: 'global-form-error',
                                        role: 'alert',
                                        tabIndex: -1,
                                        textColor: 'danger600',
                                        textAlign: 'center',
                                        children: apiError
                                    })
                                }) : null
                            ]
                        }),
                        /*#__PURE__*/ jsx(Form, {
                            method: 'PUT',
                            initialValues: otpStep ? { code: '' } : { email: '', password: '', rememberMe: false },
                            onSubmit: (values)=>{
                                if (otpStep) {
                                    handleVerifyOtp(values);
                                } else {
                                    handleLogin(values);
                                }
                            },
                            validationSchema: otpStep ? OTP_SCHEMA : LOGIN_SCHEMA,
                            children: /*#__PURE__*/ jsxs(Flex, {
                                direction: 'column',
                                alignItems: 'stretch',
                                gap: 6,
                                children: [
                                    otpStep ? /*#__PURE__*/ jsx(OtpField, {}) : [
                                        /*#__PURE__*/ jsx(MemoizedInputRenderer, {
                                            label: formatMessage({ id: 'Auth.form.email.label', defaultMessage: 'Email' }),
                                            name: 'email',
                                            placeholder: formatMessage({ id: 'Auth.form.email.placeholder', defaultMessage: 'kai@doe.com' }),
                                            required: true,
                                            type: 'email'
                                        }, 'email'),
                                        /*#__PURE__*/ jsx(MemoizedInputRenderer, {
                                            label: formatMessage({ id: 'global.password', defaultMessage: 'Password' }),
                                            name: 'password',
                                            required: true,
                                            type: 'password'
                                        }, 'password'),
                                        /*#__PURE__*/ jsx(MemoizedInputRenderer, {
                                            label: formatMessage({ id: 'Auth.form.rememberMe.label', defaultMessage: 'Remember me' }),
                                            name: 'rememberMe',
                                            type: 'checkbox'
                                        }, 'rememberMe')
                                    ],
                                    /*#__PURE__*/ jsxs(Flex, {
                                        direction: 'column',
                                        gap: 3,
                                        children: [
                                            /*#__PURE__*/ jsx(Button, {
                                                fullWidth: true,
                                                type: 'submit',
                                                disabled: !otpStep && isLoggingIn,
                                                children: formatMessage({
                                                    id: otpStep ? 'Auth.form.button.verifyOtp' : 'Auth.form.button.login',
                                                    defaultMessage: otpStep ? 'Verify OTP' : isLoggingIn ? 'Login...' : 'Login'
                                                })
                                            }),
                                            otpStep ? /*#__PURE__*/ jsxs(Flex, {
                                                gap: 2,
                                                justifyContent: 'space-between',
                                                alignItems: 'stretch',
                                                children: [
                                                    /*#__PURE__*/ jsx(Button, {
                                                        fullWidth: true,
                                                        style: { minWidth: '11rem' },
                                                        type: 'button',
                                                        variant: 'secondary',
                                                        onClick: ()=>setOtpStep(null),
                                                        disabled: isResendingOtp,
                                                        children: formatMessage({ id: 'Auth.form.button.back', defaultMessage: 'Back' })
                                                    }),
                                                    /*#__PURE__*/ jsx(Button, {
                                                        fullWidth: true,
                                                        style: { minWidth: '11rem' },
                                                        type: 'button',
                                                        variant: 'tertiary',
                                                        onClick: handleResendOtp,
                                                        disabled: isResendingOtp,
                                                        children: formatMessage({
                                                            id: 'Auth.form.button.resendOtp',
                                                            defaultMessage: isResendingOtp ? 'Resending...' : 'Resend OTP'
                                                        })
                                                    })
                                                ]
                                            }) : null
                                        ]
                                    })
                                ]
                            })
                        }),
                        children
                    ]
                }),
                /*#__PURE__*/ jsx(Flex, {
                    justifyContent: 'center',
                    children: /*#__PURE__*/ jsx(Box, {
                        paddingTop: 4,
                        children: /*#__PURE__*/ jsx(Link, {
                            isExternal: false,
                            tag: NavLink,
                            to: '/auth/forgot-password',
                            children: formatMessage({
                                id: 'Auth.link.forgot-password',
                                defaultMessage: 'Forgot your password?'
                            })
                        })
                    })
                })
            ]
        })
    });
};

export { Login };

Then create the CommonJS mirror too:

scripts/strapi-admin-otp-patch/pages/Auth/components/Login.js
'use strict';

var jsxRuntime = require('react/jsx-runtime');
var React = require('react');
var designSystem = require('@strapi/design-system');
var camelCase = require('lodash/camelCase');
var reactIntl = require('react-intl');
var reactRouterDom = require('react-router-dom');
var yup = require('yup');
var Form = require('../../../components/Form.js');
var Renderer = require('../../../components/FormInputs/Renderer.js');
var UnauthenticatedLogo = require('../../../components/UnauthenticatedLogo.js');
var UnauthenticatedLayout = require('../../../layouts/UnauthenticatedLayout.js');
var hooks = require('../../../core/store/hooks.js');
var Notifications = require('../../../features/Notifications.js');
var reducer = require('../../../reducer.js');
var auth = require('../../../services/auth.js');
var deviceId = require('../../../utils/deviceId.js');
var translatedErrors = require('../../../utils/translatedErrors.js');

function _interopNamespaceDefault(e) {
  var n = Object.create(null);
  if (e) {
    Object.keys(e).forEach(function (k) {
      if (k !== 'default') {
        var d = Object.getOwnPropertyDescriptor(e, k);
        Object.defineProperty(n, k, d.get ? d : {
          enumerable: true,
          get: function () { return e[k]; }
        });
      }
    });
  }
  n.default = e;
  return Object.freeze(n);
}

var React__namespace = /*#__PURE__*/_interopNamespaceDefault(React);
var yup__namespace = /*#__PURE__*/_interopNamespaceDefault(yup);

const OTP_LENGTH = 6;
const OTP_DIGIT_INPUT_STYLE = {
    width: '3.75rem',
    height: '4.5rem',
    borderRadius: '1rem',
    borderStyle: 'solid',
    borderWidth: '2px',
    borderColor: 'var(--strapi-colors-neutral500)',
    backgroundColor: 'var(--strapi-colors-neutral200)',
    color: 'var(--strapi-colors-neutral800)',
    fontSize: '1.85rem',
    fontWeight: 700,
    textAlign: 'center',
    outline: 'none',
    transition: 'all 160ms ease',
    boxShadow: 'none'
};
const sanitizeOtp = (value = '')=>value.replace(/\D/g, '').slice(0, OTP_LENGTH);
const createOtpDigits = (value = '')=>Array.from({ length: OTP_LENGTH }, (_, index)=>value[index] ?? '');
const LOGIN_SCHEMA = yup__namespace.object().shape({
    email: yup__namespace.string().nullable().email({
        id: translatedErrors.translatedErrors.email.id,
        defaultMessage: 'Not a valid email'
    }).required(translatedErrors.translatedErrors.required),
    password: yup__namespace.string().required(translatedErrors.translatedErrors.required).nullable(),
    rememberMe: yup__namespace.bool().nullable()
});
const OTP_SCHEMA = yup__namespace.object().shape({
    code: yup__namespace.string().nullable().matches(/^\d{6}$/, {
        message: 'OTP code must be a 6-digit number'
    }).required(translatedErrors.translatedErrors.required)
});
const OtpField = ()=>{
    const { values, errors, onChange, isSubmitting } = Form.useForm('OtpField', (state)=>state);
    const inputRefs = React__namespace.useRef([]);
    const codeValue = typeof values.code === 'string' ? values.code : '';
    const digits = React__namespace.useMemo(()=>createOtpDigits(codeValue), [codeValue]);
    const [activeIndex, setActiveIndex] = React__namespace.useState(0);
    const focusInput = React__namespace.useCallback((index)=>{
        window.requestAnimationFrame(()=>{
            const element = inputRefs.current[index];
            if (element) {
                element.focus();
                element.select();
            }
        });
    }, []);
    React__namespace.useEffect(()=>{
        if (!codeValue) {
            focusInput(0);
        }
    }, [codeValue, focusInput]);
    const commitDigits = React__namespace.useCallback((nextDigits, focusIndex)=>{
        const nextCode = nextDigits.join('');
        onChange('code', nextCode || null);
        if (typeof focusIndex === 'number') {
            focusInput(focusIndex);
        }
    }, [focusInput, onChange]);
    const handleChange = (index)=>(event)=>{
        const incomingValue = sanitizeOtp(event.target.value);
        const nextDigits = [...digits];
        if (!incomingValue) {
            nextDigits[index] = '';
            commitDigits(nextDigits, index);
            return;
        }
        incomingValue.split('').forEach((digit, offset)=>{
            const targetIndex = index + offset;
            if (targetIndex < OTP_LENGTH) {
                nextDigits[targetIndex] = digit;
            }
        });
        const nextFocusIndex = Math.min(index + incomingValue.length, OTP_LENGTH - 1);
        commitDigits(nextDigits, nextFocusIndex);
    };
    const handleKeyDown = (index)=>(event)=>{
        if (event.key === 'Backspace') {
            event.preventDefault();
            const nextDigits = [...digits];
            if (nextDigits[index]) {
                nextDigits[index] = '';
                commitDigits(nextDigits, index);
            } else if (index > 0) {
                nextDigits[index - 1] = '';
                commitDigits(nextDigits, index - 1);
            }
            return;
        }
        if (event.key === 'ArrowLeft' && index > 0) {
            event.preventDefault();
            focusInput(index - 1);
            return;
        }
        if (event.key === 'ArrowRight' && index < OTP_LENGTH - 1) {
            event.preventDefault();
            focusInput(index + 1);
        }
    };
    const handlePaste = (event)=>{
        const pastedValue = sanitizeOtp(event.clipboardData.getData('text'));
        if (!pastedValue) {
            return;
        }
        event.preventDefault();
        const nextDigits = createOtpDigits();
        pastedValue.split('').forEach((digit, index)=>{
            nextDigits[index] = digit;
        });
        commitDigits(nextDigits, Math.min(pastedValue.length, OTP_LENGTH) - 1);
    };
    return /*#__PURE__*/ jsxRuntime.jsxs(designSystem.Box, {
        padding: 5,
        style: {
            borderRadius: '1.25rem',
            background: 'var(--strapi-colors-neutral150)',
            border: '1px solid var(--strapi-colors-neutral300)'
        },
        children: [
            /*#__PURE__*/ jsxRuntime.jsx(designSystem.Box, {
                paddingBottom: 2,
                children: /*#__PURE__*/ jsxRuntime.jsx(designSystem.Flex, {
                    gap: 3,
                    justifyContent: "center",
                    wrap: "wrap",
                    onPaste: handlePaste,
                    children: digits.map((digit, index)=>/*#__PURE__*/ jsxRuntime.jsx("input", {
                        "aria-invalid": errors.code ? 'true' : 'false',
                        "aria-label": `OTP digit ${index + 1}`,
                        autoComplete: index === 0 ? 'one-time-code' : 'off',
                        disabled: isSubmitting,
                        inputMode: "numeric",
                        maxLength: 6,
                        onChange: handleChange(index),
                        onFocus: ()=>setActiveIndex(index),
                        onKeyDown: handleKeyDown(index),
                        pattern: "[0-9]*",
                        ref: (element)=>{
                            inputRefs.current[index] = element;
                        },
                        style: {
                            ...OTP_DIGIT_INPUT_STYLE,
                            borderColor: errors.code ? 'var(--strapi-colors-danger600)' : activeIndex === index ? 'var(--strapi-colors-primary600)' : 'var(--strapi-colors-neutral500)',
                            backgroundColor: activeIndex === index ? 'var(--strapi-colors-neutral0)' : 'var(--strapi-colors-neutral200)',
                            boxShadow: errors.code ? '0 0 0 1px var(--strapi-colors-danger600)' : activeIndex === index ? '0 0 0 3px rgba(73, 69, 255, 0.18)' : 'inset 0 0 0 1px var(--strapi-colors-neutral600)'
                        },
                        type: "text",
                        value: digit
                    }, index))
                })
            }),
            errors.code ? /*#__PURE__*/ jsxRuntime.jsx(designSystem.Box, {
                paddingTop: 3,
                children: /*#__PURE__*/ jsxRuntime.jsx(designSystem.Typography, {
                    id: "otp-code-error",
                    variant: "pi",
                    textColor: "danger600",
                    children: errors.code
                })
            }) : null
        ]
    });
};

const Login = ({ children })=>{
    const [apiError, setApiError] = React__namespace.useState();
    const [otpStep, setOtpStep] = React__namespace.useState(null);
    const { formatMessage } = reactIntl.useIntl();
    const { search: searchString } = reactRouterDom.useLocation();
    const query = React__namespace.useMemo(()=>new URLSearchParams(searchString), [searchString]);
    const navigate = reactRouterDom.useNavigate();
    const dispatch = hooks.useTypedDispatch();
    const { toggleNotification } = Notifications.useNotification();
    const [adminLoginWithOtp, { isLoading: isLoggingIn }] = auth.useAdminLoginWithOtpMutation();
    const [verifyAdminLoginOtp] = auth.useVerifyAdminLoginOtpMutation();
    const [resendAdminLoginOtp, { isLoading: isResendingOtp }] = auth.useResendAdminLoginOtpMutation();
    React__namespace.useEffect(()=>{
        document.title = 'Admin Dashboard';
    }, []);
    const handleLogin = async (body)=>{
        setApiError(undefined);
        const res = await adminLoginWithOtp({
            ...body,
            deviceId: deviceId.getOrCreateDeviceId()
        });
        if ('error' in res) {
            const message = res.error.message ?? 'Something went wrong';
            if (camelCase(message).toLowerCase() === 'usernotactive') {
                navigate('/auth/oops');
                return;
            }
            setApiError(message);
        } else {
            setOtpStep({
                challengeId: res.data.challengeId,
                expiresAt: res.data.expiresAt,
                maskedEmail: res.data.maskedEmail,
                rememberMe: body.rememberMe
            });
        }
    };
    const handleVerifyOtp = async ({ code })=>{
        if (!otpStep) {
            return;
        }
        setApiError(undefined);
        const res = await verifyAdminLoginOtp({
            challengeId: otpStep.challengeId,
            code
        });
        if ('error' in res) {
            setApiError(res.error.message ?? 'Something went wrong');
        } else {
            dispatch(reducer.login({
                token: res.data.token,
                persist: otpStep.rememberMe
            }));
            const redirectTo = query.get('redirectTo');
            const redirectUrl = redirectTo ? decodeURIComponent(redirectTo) : '/';
            navigate(redirectUrl);
        }
    };
    const handleResendOtp = async ()=>{
        if (!otpStep) {
            return;
        }
        setApiError(undefined);
        const res = await resendAdminLoginOtp({
            challengeId: otpStep.challengeId
        });
        if ('error' in res) {
            setApiError(res.error.message ?? 'Something went wrong');
        } else {
            setOtpStep({
                ...otpStep,
                expiresAt: res.data.expiresAt,
                maskedEmail: res.data.maskedEmail
            });
            toggleNotification({
                type: 'success',
                title: formatMessage({
                    id: 'Auth.notification.otpResent.title',
                    defaultMessage: 'OTP resent'
                }),
                message: formatMessage({
                    id: 'Auth.notification.otpResent.message',
                    defaultMessage: `A new OTP has been sent to ${res.data.maskedEmail}.`
                })
            });
        }
    };
    return /*#__PURE__*/ jsxRuntime.jsx(UnauthenticatedLayout.UnauthenticatedLayout, {
        children: /*#__PURE__*/ jsxRuntime.jsxs(designSystem.Main, {
            children: [
                /*#__PURE__*/ jsxRuntime.jsxs(UnauthenticatedLayout.LayoutContent, {
                    children: [
                        /*#__PURE__*/ jsxRuntime.jsxs(UnauthenticatedLayout.Column, {
                            children: [
                                /*#__PURE__*/ jsxRuntime.jsx(UnauthenticatedLogo.Logo, {}),
                                /*#__PURE__*/ jsxRuntime.jsx(designSystem.Box, {
                                    paddingTop: 6,
                                    paddingBottom: 1,
                                    children: /*#__PURE__*/ jsxRuntime.jsx(designSystem.Typography, {
                                        variant: "alpha",
                                        tag: "h1",
                                        textAlign: "center",
                                        children: formatMessage({
                                            id: otpStep ? 'Auth.form.otp.title' : 'Auth.form.welcome.title',
                                            defaultMessage: otpStep ? 'Enter your OTP code' : 'Welcome!'
                                        })
                                    })
                                }),
                                /*#__PURE__*/ jsxRuntime.jsx(designSystem.Box, {
                                    paddingBottom: otpStep ? 5 : 7,
                                    children: /*#__PURE__*/ jsxRuntime.jsx(designSystem.Typography, {
                                        variant: "epsilon",
                                        textColor: "neutral600",
                                        textAlign: "center",
                                        display: "block",
                                        children: formatMessage({
                                            id: otpStep ? 'Auth.form.otp.subtitle' : 'Auth.form.welcome.subtitle',
                                            defaultMessage: otpStep ? `We sent a 6-digit code to ${otpStep.maskedEmail}` : 'Log in to your Strapi account'
                                        })
                                    })
                                }),
                                otpStep ? /*#__PURE__*/ jsxRuntime.jsx(designSystem.Box, {
                                    paddingBottom: 5,
                                    children: /*#__PURE__*/ jsxRuntime.jsx(designSystem.Typography, {
                                        variant: "pi",
                                        textColor: "neutral600",
                                        textAlign: "center",
                                        children: `OTP expires at ${new Date(otpStep.expiresAt).toLocaleTimeString()}`
                                    })
                                }) : null,
                                apiError ? /*#__PURE__*/ jsxRuntime.jsx(designSystem.Box, {
                                    paddingBottom: 4,
                                    children: /*#__PURE__*/ jsxRuntime.jsx(designSystem.Typography, {
                                        id: "global-form-error",
                                        role: "alert",
                                        tabIndex: -1,
                                        textColor: "danger600",
                                        textAlign: "center",
                                        children: apiError
                                    })
                                }) : null
                            ]
                        }),
                        /*#__PURE__*/ jsxRuntime.jsx(Form.Form, {
                            method: "PUT",
                            initialValues: otpStep ? {
                                code: ''
                            } : {
                                email: '',
                                password: '',
                                rememberMe: false
                            },
                            onSubmit: (values)=>{
                                if (otpStep) {
                                    handleVerifyOtp(values);
                                } else {
                                    handleLogin(values);
                                }
                            },
                            validationSchema: otpStep ? OTP_SCHEMA : LOGIN_SCHEMA,
                            children: /*#__PURE__*/ jsxRuntime.jsxs(designSystem.Flex, {
                                direction: "column",
                                alignItems: "stretch",
                                gap: 6,
                                children: [
                                    otpStep ? /*#__PURE__*/ jsxRuntime.jsx(OtpField, {}) : [
                                        /*#__PURE__*/ jsxRuntime.jsx(Renderer.InputRenderer, {
                                            label: formatMessage({
                                                id: 'Auth.form.email.label',
                                                defaultMessage: 'Email'
                                            }),
                                            name: "email",
                                            placeholder: formatMessage({
                                                id: 'Auth.form.email.placeholder',
                                                defaultMessage: 'kai@doe.com'
                                            }),
                                            required: true,
                                            type: "email"
                                        }, "email"),
                                        /*#__PURE__*/ jsxRuntime.jsx(Renderer.InputRenderer, {
                                            label: formatMessage({
                                                id: 'global.password',
                                                defaultMessage: 'Password'
                                            }),
                                            name: "password",
                                            required: true,
                                            type: "password"
                                        }, "password"),
                                        /*#__PURE__*/ jsxRuntime.jsx(Renderer.InputRenderer, {
                                            label: formatMessage({
                                                id: 'Auth.form.rememberMe.label',
                                                defaultMessage: 'Remember me'
                                            }),
                                            name: "rememberMe",
                                            type: "checkbox"
                                        }, "rememberMe")
                                    ],
                                    /*#__PURE__*/ jsxRuntime.jsxs(designSystem.Flex, {
                                        direction: "column",
                                        gap: 3,
                                        children: [
                                            /*#__PURE__*/ jsxRuntime.jsx(designSystem.Button, {
                                                fullWidth: true,
                                                type: "submit",
                                                disabled: !otpStep && isLoggingIn,
                                                children: formatMessage({
                                                    id: otpStep ? 'Auth.form.button.verifyOtp' : 'Auth.form.button.login',
                                                    defaultMessage: otpStep ? 'Verify OTP' : isLoggingIn ? 'Login...' : 'Login'
                                                })
                                            }),
                                            otpStep ? /*#__PURE__*/ jsxRuntime.jsxs(designSystem.Flex, {
                                                gap: 2,
                                                justifyContent: "space-between",
                                                alignItems: "stretch",
                                                children: [
                                                    /*#__PURE__*/ jsxRuntime.jsx(designSystem.Button, {
                                                        fullWidth: true,
                                                        style: {
                                                            minWidth: '11rem'
                                                        },
                                                        type: "button",
                                                        variant: "secondary",
                                                        onClick: ()=>setOtpStep(null),
                                                        disabled: isResendingOtp,
                                                        children: formatMessage({
                                                            id: 'Auth.form.button.back',
                                                            defaultMessage: 'Back'
                                                        })
                                                    }),
                                                    /*#__PURE__*/ jsxRuntime.jsx(designSystem.Button, {
                                                        fullWidth: true,
                                                        style: {
                                                            minWidth: '11rem'
                                                        },
                                                        type: "button",
                                                        variant: "tertiary",
                                                        onClick: handleResendOtp,
                                                        disabled: isResendingOtp,
                                                        children: formatMessage({
                                                            id: 'Auth.form.button.resendOtp',
                                                            defaultMessage: isResendingOtp ? 'Resending...' : 'Resend OTP'
                                                        })
                                                    })
                                                ]
                                            }) : null
                                        ]
                                    })
                                ]
                            })
                        }),
                        children
                    ]
                }),
                /*#__PURE__*/ jsxRuntime.jsx(designSystem.Flex, {
                    justifyContent: "center",
                    children: /*#__PURE__*/ jsxRuntime.jsx(designSystem.Box, {
                        paddingTop: 4,
                        children: /*#__PURE__*/ jsxRuntime.jsx(designSystem.Link, {
                            isExternal: false,
                            tag: reactRouterDom.NavLink,
                            to: "/auth/forgot-password",
                            children: formatMessage({
                                id: 'Auth.link.forgot-password',
                                defaultMessage: 'Forgot your password?'
                            })
                        })
                    })
                })
            ]
        })
    });
};

exports.Login = Login;

Step 10: Create the Patch Copier Script

Create:

scripts/apply-strapi-admin-otp-patch.js
const fs = require("fs");
const path = require("path");

const rootDir = process.cwd();

const patchFiles = [
  {
    source: path.join(rootDir, "scripts", "strapi-admin-otp-patch", "pages", "Auth", "components", "Login.js"),
    target: path.join(rootDir, "node_modules", "@strapi", "admin", "dist", "admin", "admin", "src", "pages", "Auth", "components", "Login.js"),
  },
  {
    source: path.join(rootDir, "scripts", "strapi-admin-otp-patch", "pages", "Auth", "components", "Login.mjs"),
    target: path.join(rootDir, "node_modules", "@strapi", "admin", "dist", "admin", "admin", "src", "pages", "Auth", "components", "Login.mjs"),
  },
  {
    source: path.join(rootDir, "scripts", "strapi-admin-otp-patch", "services", "auth.js"),
    target: path.join(rootDir, "node_modules", "@strapi", "admin", "dist", "admin", "admin", "src", "services", "auth.js"),
  },
  {
    source: path.join(rootDir, "scripts", "strapi-admin-otp-patch", "services", "auth.mjs"),
    target: path.join(rootDir, "node_modules", "@strapi", "admin", "dist", "admin", "admin", "src", "services", "auth.mjs"),
  },
];

const generatedCacheDirs = [
  path.join(rootDir, "node_modules", ".strapi", "vite"),
  path.join(rootDir, ".strapi", "client"),
];

const missing = patchFiles.find(
  ({ source, target }) => !fs.existsSync(source) || !fs.existsSync(target)
);

if (missing) {
  console.warn("[admin-otp-patch] Skipping Strapi admin patch because a source or target file is missing.");
  process.exit(0);
}

for (const { source, target } of patchFiles) {
  fs.copyFileSync(source, target);
}

for (const generatedDir of generatedCacheDirs) {
  if (fs.existsSync(generatedDir)) {
    fs.rmSync(generatedDir, { recursive: true, force: true });
  }
}

console.log(
  "[admin-otp-patch] Applied Strapi admin OTP login patch and cleared Strapi admin caches."
);

Clearing the generated cache directories is important. Otherwise Strapi develop mode may keep serving the old admin login bundle.


Step 11: Update package.json Scripts

Update:

package.json
{
  "scripts": {
    "build": "strapi build",
    "dev": "strapi develop",
    "develop": "strapi develop",
    "prebuild": "node scripts/apply-strapi-admin-otp-patch.js",
    "predev": "node scripts/apply-strapi-admin-otp-patch.js",
    "predevelop": "node scripts/apply-strapi-admin-otp-patch.js",
    "postinstall": "node scripts/apply-strapi-admin-otp-patch.js",
    "start": "strapi start"
  }
}

Step 12: Install, Build, and Test

npm install
npm run build
npm run develop

Then test this flow:

  1. Open the admin login page
  2. Enter valid email and password
  3. Confirm you see the OTP screen instead of entering the dashboard directly
  4. Check the email inbox for the OTP
  5. Enter the OTP
  6. Confirm the session is created only after OTP verification
  7. Click resend and check the toast and disabled state

Troubleshooting

  • If email and password still take you straight to the dashboard, the patch was not applied or Strapi is serving a stale admin bundle. Re-check the patch script, then clear node_modules/.strapi/vite and .strapi/client.
  • If you get ERR_PACKAGE_PATH_NOT_EXPORTED for Strapi session auth, use the local src/utils/strapi-session-auth.ts helper shown above instead of importing the internal module directly from @strapi/admin.
  • If the OTP screen looks outdated after a code change, stop the dev server, rerun npm run develop, and hard refresh the admin page in the browser.
  • If resend or first login is slow in production, check the timing logs in the OTP service to see whether credential validation, hashing, store access, or email sending is the slow step.
  • If login works but the admin session does not persist correctly in production, confirm URL is set to the real HTTPS domain and IS_PROXIED=true is enabled behind your reverse proxy.
  • If you upgrade Strapi and the login screen stops working, compare the current Strapi admin file paths with the target paths used in your patch copier script.

Production Notes

  • Keep the admin patch script in postinstall and prebuild
  • Use real secrets and a real production database
  • Use a real email provider
  • Set IS_PROXIED=true when deploying behind Coolify or another reverse proxy so secure cookie handling works correctly
  • Read the timing logs in the OTP service if production login feels slow
  • If you deploy with Coolify, Railway, Render, or a similar platform, make sure your install and build steps still run the patch copier before the Strapi admin is built

Security Notes

  • Email OTP is much better than password-only login
  • Hash OTP values before storing them
  • Keep the OTP lifetime short, ideally 3 to 5 minutes
  • Expire OTP sessions
  • Limit both attempts and resends
  • Add IP and email based rate limiting around login, resend, and verify endpoints
  • Create the final Strapi admin session only after OTP verification succeeds
  • Email OTP improves security, but it is still weaker than app-based TOTP if the attacker also has access to the admin inbox

Final Thoughts

This implementation gives the Strapi admin panel a practical second layer of authentication without relying on a paid plugin. If you create the files above, keep the patch script in place, and test the full login flow carefully, you can use the same feature in an existing Strapi project or a new one.

The most important parts are the OTP backend service, the local session helper, the patched admin login screen, and the patch copier script. If those four areas are added correctly, the rest of the setup becomes much easier to maintain.