May 27, 2026

Testing OTP flows with Playwright

Time-based one-time passwords are awkward to test because the valid code changes every few seconds and usually lives outside the application UI. A person opens an authenticator app, reads the current code, and types it into the login form. Your Playwright test needs to do the same thing without depending on a phone, a QR-code scan, or a manually seeded database row.

In this post we'll use Playwright with Mailisk's Authenticator (TOTP) support. We'll run an end-to-end test that:

  1. Logs in with username and password.
  2. Reads the shared secret from the MFA setup flow or uses a saved test device.
  3. Asks Mailisk for a valid TOTP code.
  4. Submits the code in the browser and confirms the user reaches the authenticated area.

This works well for staging and CI because the test can generate the same kind of short-lived code a real authenticator app would generate.

Playwright

We'll reuse a standard Playwright setup. If you're new to Playwright, start with the official docs; the examples below assume Playwright is already installed and configured.

Mailisk

For TOTP tests we need a Mailisk API key and the mailisk Node SDK:

npm install --save-dev mailisk

Add the API key to your local .env file or CI secret store:

MAILISK_API_KEY=<your-api-key>
MAILISK_API_URL=<optional-local-or-preview-api-url>
TEST_USER_EMAIL=<test-user-email>
TEST_USER_PASSWORD=<test-user-password>
TEST_TOTP_SHARED_SECRET=<base32-secret-for-preconfigured-mfa-user>

Keep real shared secrets out of source control. If your test needs a stable TOTP seed, store it in your CI secret manager, create it during test setup, or save it as a short-lived Mailisk device with an expiration time.

Load the .env file from playwright.config.ts or your test setup file:

import path from "path";
import dotenv from "dotenv";

dotenv.config({ path: path.join(__dirname, ".env") });

Creating the Mailisk client

We'll create a small helper so every test fails clearly when the API key is missing:

import { MailiskClient } from "mailisk";

function createMailiskClient() {
  const apiKey = process.env.MAILISK_API_KEY;

  if (!apiKey) {
    throw new Error("MAILISK_API_KEY is required.");
  }

  return new MailiskClient({
    apiKey,
    baseUrl: process.env.MAILISK_API_URL,
  });
}

MAILISK_API_URL is optional. Most projects can leave it unset and use Mailisk's hosted API. It is useful when running against a local or preview environment.

Testing MFA setup

Many applications show a QR code and a Base32 shared secret when a user enables authenticator-based MFA. A Playwright test can capture that shared secret, ask Mailisk for the current OTP, and submit the code back into the page.

import { expect, test } from "@playwright/test";
import { MailiskClient } from "mailisk";

function createMailiskClient() {
  const apiKey = process.env.MAILISK_API_KEY;

  if (!apiKey) {
    throw new Error("MAILISK_API_KEY is required.");
  }

  return new MailiskClient({
    apiKey,
    baseUrl: process.env.MAILISK_API_URL,
  });
}

test.describe("TOTP setup", () => {
  test.skip(!process.env.MAILISK_API_KEY, "Set MAILISK_API_KEY before running this example.");

  test("enables MFA with a generated authenticator code", async ({ page }) => {
    const mailisk = createMailiskClient();

    await page.goto("http://localhost:3000/settings/security");
    await page.getByTestId("start-mfa").click();

    const sharedSecret = (await page.getByTestId("shared-secret").textContent())?.trim();
    expect(sharedSecret).toMatch(/^[A-Z2-7]+$/);

    const otp = await mailisk.getTotpOtpBySharedSecret(sharedSecret!, {
      min_seconds_until_expire: 10,
    });
    expect(otp.code).toMatch(/^\d{6}$/);

    await page.getByTestId("otp-code").fill(otp.code);
    await page.getByTestId("verify-mfa").click();

    await expect(page.getByTestId("mfa-success")).toContainText("MFA enabled");
  });
});

The important part is that the shared secret never needs to be pasted into the test file. The app exposes it during setup, Playwright reads it, and Mailisk generates the current code.

Pass min_seconds_until_expire to wait for the next TOTP period when the current code has fewer seconds left:

const otp = await mailisk.getTotpOtpBySharedSecret("JBSWY3DPEHPK3PXP", {
  min_seconds_until_expire: 10,
});

Full login flow

Once MFA is enabled for a test user, the main regression test is the login flow:

  • Open the login page.
  • Submit username and password.
  • Wait for the TOTP challenge.
  • Generate the current authenticator code.
  • Submit the code and assert the user is authenticated.

If your test user has a known shared secret, pass it through an environment variable:

TEST_TOTP_SHARED_SECRET=<base32-shared-secret>

Then use it only at the point where the TOTP challenge is visible:

import { expect, test } from "@playwright/test";
import { MailiskClient } from "mailisk";

function createMailiskClient() {
  const apiKey = process.env.MAILISK_API_KEY;

  if (!apiKey) {
    throw new Error("MAILISK_API_KEY is required.");
  }

  return new MailiskClient({
    apiKey,
    baseUrl: process.env.MAILISK_API_URL,
  });
}

test.describe("TOTP login", () => {
  test.skip(!process.env.MAILISK_API_KEY, "Set MAILISK_API_KEY before running this example.");
  test.skip(!process.env.TEST_TOTP_SHARED_SECRET, "Set TEST_TOTP_SHARED_SECRET before running this example.");

  test("logs in with username, password, and TOTP", async ({ page }) => {
    const mailisk = createMailiskClient();
    const sharedSecret = process.env.TEST_TOTP_SHARED_SECRET!;

    await page.goto("http://localhost:3000/login");
    await page.getByTestId("email").fill(process.env.TEST_USER_EMAIL ?? "qa@example.test");
    await page.getByTestId("password").fill(process.env.TEST_USER_PASSWORD ?? "password");
    await page.getByTestId("login-submit").click();

    await expect(page.getByTestId("totp-challenge")).toBeVisible();

    const otp = await mailisk.getTotpOtpBySharedSecret(sharedSecret, {
      min_seconds_until_expire: 10,
    });

    await page.getByTestId("totp-code").fill(otp.code);
    await page.getByTestId("totp-submit").click();

    await expect(page).toHaveURL(/\/dashboard$/);
    await expect(page.getByTestId("dashboard")).toBeVisible();
  });
});

The min_seconds_until_expire option handles the annoying edge case where a code is generated right before the TOTP window rolls over. If fewer than ten seconds remain, Mailisk waits for the next code and returns that instead.

Reusing saved TOTP devices

For some suites it is cleaner to save a virtual authenticator device in Mailisk, fetch codes by device ID, and delete the device when the test finishes. This is useful when a setup step creates the shared secret once and later tests only need the current code.

Saving a device in Mailisk does not enroll that device in your application. It stores a copy of the same shared secret your application already uses, so tests can request valid codes by device ID.

import { expect, test } from "@playwright/test";
import { MailiskClient, type TotpDevice } from "mailisk";

const TEST_SHARED_SECRET = "JBSWY3DPEHPK3PXP";

function createMailiskClient() {
  const apiKey = process.env.MAILISK_API_KEY;

  if (!apiKey) {
    throw new Error("MAILISK_API_KEY is required.");
  }

  return new MailiskClient({
    apiKey,
    baseUrl: process.env.MAILISK_API_URL,
  });
}

function uniqueLabel(label: string) {
  return `blog-${label}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

function expiresAtInMinutes(minutes: number) {
  return new Date(Date.now() + minutes * 60 * 1000).toISOString();
}

test.describe("Saved TOTP devices", () => {
  test.skip(!process.env.MAILISK_API_KEY, "Set MAILISK_API_KEY before running this example.");

  test("creates a reusable authenticator, gets a code, then deletes it", async () => {
    const mailisk = createMailiskClient();
    let device: TotpDevice | undefined;

    try {
      device = await mailisk.createCustomTotpDevice({
        name: uniqueLabel("github-staging"),
        secret: TEST_SHARED_SECRET,
        issuer: uniqueLabel("github"),
        username: `${uniqueLabel("qa")}@example.test`,
        digits: 6,
        period: 30,
        algorithm: "SHA1",
        expiresAt: expiresAtInMinutes(10),
      });

      const listedDevices = await mailisk.listTotpDevices({
        issuer: device.issuer ?? undefined,
        username: device.username ?? undefined,
      });

      expect(listedDevices.items.some((item) => item.id === device?.id)).toBe(true);

      const otp = await mailisk.getTotpOtpByDeviceId(device.id, {
        min_seconds_until_expire: 10,
      });
      expect(otp.code).toMatch(/^\d{6}$/);
      expect(Number.isNaN(Date.parse(otp.expires))).toBe(false);
    } finally {
      if (device) {
        await mailisk.deleteTotpDevice(device.id).catch(() => undefined);
      }
    }
  });
});

The finally block matters. If the assertion fails after device creation, the test still attempts to delete the saved authenticator. The short expiresAt value is a second layer of cleanup for interrupted local runs or canceled CI jobs.

The TEST_SHARED_SECRET above is a public example value. Use a secret created specifically for your test environment when testing a real application.

Combining TOTP with email verification

Authentication flows often need more than one out-of-band step. For example, your test might need to create a new account, verify the email address, enable MFA, then log in again with TOTP.

Mailisk can cover both pieces:

  • Use a namespace email address like qa.${Date.now()}@your-namespace.mailisk.net for sign-up.
  • Read the verification email with mailisk.searchInbox(namespace, { to_addr_prefix: testEmailAddress }).
  • Continue through MFA setup and generate the TOTP code with getTotpOtpBySharedSecret.

That keeps the whole auth journey inside one Playwright spec without manual inbox checks or authenticator apps.

Troubleshooting flaky TOTP tests

TOTP failures are usually timing or environment problems:

  • Generate the code only after the TOTP challenge is visible. Codes are short-lived, so don't create one before a slow username/password step.
  • Use min_seconds_until_expire when requesting a code. Mailisk will wait for the next TOTP period when the current code is too close to expiry, which keeps timing logic server-side and helps avoid flaky submissions.
  • Make sure the app and the test use the same TOTP settings: digits, period, and algorithm. The most common defaults are 6 digits, 30 seconds, and SHA1.
  • Avoid parallel tests sharing the same user unless each test has its own authenticator secret. Two tests logging into the same account can invalidate each other's sessions or setup state.
  • Store shared secrets in environment variables or CI secrets, not in the repository. Rotate them if they leak in logs.

Final spec

Here's a complete Playwright spec that covers setup, login, code generation, and cleanup patterns.

The saved-device login test assumes TEST_USER_EMAIL is already configured in your application with TEST_TOTP_SHARED_SECRET.

import { expect, test } from "@playwright/test";
import { MailiskClient, type TotpDevice } from "mailisk";

function createMailiskClient() {
  const apiKey = process.env.MAILISK_API_KEY;

  if (!apiKey) {
    throw new Error("MAILISK_API_KEY is required.");
  }

  return new MailiskClient({
    apiKey,
    baseUrl: process.env.MAILISK_API_URL,
  });
}

function uniqueLabel(label: string) {
  return `blog-${label}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

function expiresAtInMinutes(minutes: number) {
  return new Date(Date.now() + minutes * 60 * 1000).toISOString();
}

test.describe("TOTP testing with mailisk-node", () => {
  test.skip(!process.env.MAILISK_API_KEY, "Set MAILISK_API_KEY before running this example.");

  test("uses a shared secret from MFA setup to verify a login code", async ({ page }) => {
    const mailisk = createMailiskClient();

    await page.goto("http://localhost:3000/settings/security");
    await page.getByTestId("start-mfa").click();

    const sharedSecret = (await page.getByTestId("shared-secret").textContent())?.trim();
    expect(sharedSecret).toMatch(/^[A-Z2-7]+$/);

    const otp = await mailisk.getTotpOtpBySharedSecret(sharedSecret!, {
      min_seconds_until_expire: 10,
    });
    expect(otp.code).toMatch(/^\d{6}$/);

    await page.getByTestId("otp-code").fill(otp.code);
    await page.getByTestId("verify-mfa").click();

    await expect(page.getByTestId("mfa-success")).toContainText("MFA enabled");
  });

  test("logs in with a saved TOTP device", async ({ page }) => {
    test.skip(
      !process.env.TEST_TOTP_SHARED_SECRET,
      "Set TEST_TOTP_SHARED_SECRET to the shared secret configured for the test user.",
    );

    const mailisk = createMailiskClient();
    const sharedSecret = process.env.TEST_TOTP_SHARED_SECRET!;
    let device: TotpDevice | undefined;

    try {
      device = await mailisk.createCustomTotpDevice({
        name: uniqueLabel("staging-user"),
        secret: sharedSecret,
        issuer: uniqueLabel("app"),
        username: `${uniqueLabel("qa")}@example.test`,
        digits: 6,
        period: 30,
        algorithm: "SHA1",
        expiresAt: expiresAtInMinutes(10),
      });

      await page.goto("http://localhost:3000/login");
      await page.getByTestId("email").fill(process.env.TEST_USER_EMAIL ?? "qa@example.test");
      await page.getByTestId("password").fill(process.env.TEST_USER_PASSWORD ?? "password");
      await page.getByTestId("login-submit").click();

      await expect(page.getByTestId("totp-challenge")).toBeVisible();

      const otp = await mailisk.getTotpOtpByDeviceId(device!.id, {
        min_seconds_until_expire: 10,
      });

      await page.getByTestId("totp-code").fill(otp.code);
      await page.getByTestId("totp-submit").click();

      await expect(page).toHaveURL(/\/dashboard$/);
      await expect(page.getByTestId("dashboard")).toBeVisible();
    } finally {
      if (device) {
        await mailisk.deleteTotpDevice(device.id).catch(() => undefined);
      }
    }
  });
});

With this in place you can continuously verify that your password step, TOTP challenge, authenticator settings, and authenticated redirect all keep working. Mailisk handles the time-based code generation so the Playwright test can stay focused on the user journey.

Ready to start testing emails?
Create a free account.

Get started