Back to Blog

Automating MFA and 2FA Testing: Stop Skipping Auth Flows in Your Test Suite

Multi-factor authentication is critical for security, but it is notoriously painful to test. OTP codes that expire in 30 seconds, SMS delivery delays, and TOTP clock sync issues create a testing nightmare. Here's how to automate MFA testing without relying on real SMS delivery or manual code entry.

Published

6 min read

Reading time

Automating MFA and 2FA Testing: Stop Skipping Auth Flows in Your Test Suite

MFA is the most common reason developers skip authentication testing. "It requires a real phone number" or "the OTP code expires too fast" are the standard explanations. The result: login flows receive zero automated test coverage for roughly 50% of users who have MFA enabled.

This guide dismantles that excuse. Every MFA flow type — email OTP, SMS OTP, TOTP authenticator apps, and magic links — has a testable, automatable path that does not require actual SMS delivery or manual code transcription.


The Four MFA Flow Types and Their Test Strategies

flowchart TD
    A[MFA Challenge] --> B{Flow type}
    B -->|Email OTP| C[Read OTP from\ntest mailbox API]
    B -->|SMS OTP| D[Use test phone number\nwith provider API]
    B -->|TOTP App| E[Generate code from\nseed secret]
    B -->|Magic Link| F[Extract link from\nmailbox API]

    C --> G[Intercept + enter in test]
    D --> G
    E --> G
    F --> G[Click link in test]

Strategy 1: Email OTP via Mailbox API

For email-based OTP/magic link flows, the key is using a programmable test email inbox. Resend, Mailpit (local), or dedicated services like Mailhog provide API access to received emails:

// tests/auth/email-otp.test.ts
import { test, expect } from '@playwright/test';

// Mailpit local test inbox API
async function getLatestOtpFromEmail(email: string): Promise<string> {
  const mailpitUrl = process.env.MAILPIT_URL ?? 'http://localhost:8025';

  // Wait for email to arrive (up to 10 seconds)
  for (let i = 0; i < 10; i++) {
    const response = await fetch(`${mailpitUrl}/api/v1/messages?query=${encodeURIComponent(email)}&limit=1`);
    const data = await response.json();

    if (data.messages?.length > 0) {
      const messageId = data.messages[0].ID;
      const messageResponse = await fetch(`${mailpitUrl}/api/v1/message/${messageId}`);
      const message = await messageResponse.json();

      // Extract 6-digit OTP from email body
      const otpMatch = message.Text.match(/\b(\d{6})\b/);
      if (otpMatch) return otpMatch[1];
    }

    await new Promise((resolve) => setTimeout(resolve, 1000));
  }

  throw new Error('OTP email not received within 10 seconds');
}

test('email OTP login flow works end-to-end', async ({ page }) => {
  const testEmail = `test-${Date.now()}@example.com`;

  // Navigate to login
  await page.goto('/login');
  await page.fill('[data-testid="email-input"]', testEmail);
  await page.click('[data-testid="send-otp-btn"]');

  // Wait for "check your email" screen
  await expect(page.locator('[data-testid="otp-input-screen"]')).toBeVisible();

  // Fetch OTP from test mailbox
  const otp = await getLatestOtpFromEmail(testEmail);

  // Enter OTP
  await page.fill('[data-testid="otp-code-input"]', otp);
  await page.click('[data-testid="verify-otp-btn"]');

  // Should be authenticated
  await expect(page).toHaveURL('/dashboard');
});

test('expired OTP shows correct error', async ({ page }) => {
  // Test that your backend correctly rejects expired OTPs
  await page.goto('/login');
  await page.fill('[data-testid="email-input"]', 'test@example.com');
  await page.click('[data-testid="send-otp-btn"]');

  // Directly enter an obviously-wrong/expired code
  await page.fill('[data-testid="otp-code-input"]', '000000');
  await page.click('[data-testid="verify-otp-btn"]');

  // Should show error, not crash
  await expect(page.locator('[data-testid="otp-error"]')).toBeVisible();
  await expect(page.locator('[data-testid="otp-error"]')).toContainText(/invalid|expired/i);
});

Strategy 2: TOTP (Authenticator App) Automation

TOTP codes (Google Authenticator, Authy, etc.) are generated from a seed secret using the standard RFC 6238 algorithm. If you control the test account's TOTP secret, you can generate valid codes programmatically:

// lib/test-totp.ts
import * as OTPAuth from 'otpauth';

/**
 * Generates a valid TOTP code for the given secret.
 * The secret is the base32 seed stored when the user sets up their authenticator.
 */
export function generateTOTP(secret: string): string {
  const totp = new OTPAuth.TOTP({
    secret: OTPAuth.Secret.fromBase32(secret),
    algorithm: 'SHA1',
    digits: 6,
    period: 30,
  });

  return totp.generate();
}

// Usage in tests:
// const code = generateTOTP(process.env.TEST_ACCOUNT_TOTP_SECRET!);
// tests/auth/totp.test.ts
import { test, expect } from '@playwright/test';
import { generateTOTP } from '../lib/test-totp';

test('TOTP 2FA challenge accepts valid code', async ({ page }) => {
  // Login with password first (which triggers 2FA challenge)
  await page.goto('/login');
  await page.fill('[data-testid="email"]', process.env.TEST_2FA_EMAIL!);
  await page.fill('[data-testid="password"]', process.env.TEST_2FA_PASSWORD!);
  await page.click('[data-testid="login-btn"]');

  // Should redirect to 2FA challenge
  await expect(page).toHaveURL('/login/2fa');
  await expect(page.locator('[data-testid="totp-input"]')).toBeVisible();

  // Generate current valid code
  const totpCode = generateTOTP(process.env.TEST_ACCOUNT_TOTP_SECRET!);

  await page.fill('[data-testid="totp-input"]', totpCode);
  await page.click('[data-testid="verify-2fa-btn"]');

  // Should be fully authenticated now
  await expect(page).toHaveURL('/dashboard');
  await expect(page.locator('[data-testid="user-avatar"]')).toBeVisible();
});

test('TOTP rejects obviously invalid codes', async ({ page }) => {
  // Get to TOTP challenge...
  await navigateToTOTPChallenge(page);

  await page.fill('[data-testid="totp-input"]', '123456'); // Wrong code
  await page.click('[data-testid="verify-2fa-btn"]');

  await expect(page.locator('[data-testid="totp-error"]')).toBeVisible();
  // Should NOT be authenticated
  await expect(page).toHaveURL('/login/2fa');
});

test('TOTP rate limiting prevents brute force', async ({ page }) => {
  await navigateToTOTPChallenge(page);

  // Try multiple wrong codes
  for (let i = 0; i < 5; i++) {
    await page.fill('[data-testid="totp-input"]', `${100000 + i}`);
    await page.click('[data-testid="verify-2fa-btn"]');
  }

  // After 5 failures, should be rate limited or locked
  const errorText = await page.locator('[data-testid="totp-error"]').textContent();
  expect(errorText).toMatch(/too many attempts|locked|wait/i);
});

Strategy 3: SMS OTP via Provider Test Numbers

Major SMS providers offer test phone numbers that return pre-configured OTP codes without sending real SMS:

// Twilio Verify test credentials
// Test phone number: +15005550006 returns OTP: 1234

test('SMS OTP verification with Twilio test number', async ({ page }) => {
  await page.goto('/signup/verify-phone');

  // Use provider's test number
  await page.fill('[data-testid="phone-input"]', '+15005550006');
  await page.click('[data-testid="send-sms-btn"]');

  await expect(page.locator('[data-testid="otp-input-screen"]')).toBeVisible();

  // Enter the known test OTP for this test number
  await page.fill('[data-testid="sms-otp-input"]', '1234');
  await page.click('[data-testid="verify-sms-btn"]');

  await expect(page.locator('[data-testid="phone-verified-badge"]')).toBeVisible();
});

For Supabase Auth with phone OTP, configure the phone provider test credentials in the dashboard and use the test phone numbers in your automated tests.


Strategy 4: Magic Link Extraction

// tests/auth/magic-link.test.ts

async function extractMagicLink(email: string): Promise<string> {
  const body = await waitForEmail(email);

  // Extract the magic link URL
  const linkMatch = body.match(/https:\/\/app\.scanlyapp\.com\/auth\/callback[^\s"<>]+/);
  if (!linkMatch) throw new Error('Magic link not found in email body');

  return linkMatch[0];
}

test('magic link authenticates the user', async ({ page }) => {
  const testEmail = `magic-test-${Date.now()}@test.example.com`;

  await page.goto('/login');
  await page.fill('[data-testid="email-input"]', testEmail);
  await page.click('[data-testid="magic-link-btn"]');

  await expect(page.locator('[data-testid="check-email-message"]')).toBeVisible();

  const magicLink = await extractMagicLink(testEmail);

  // Navigate to the magic link
  await page.goto(magicLink);

  // Should be fully authenticated
  await expect(page).toHaveURL('/dashboard');
});

Related articles: Also see the OAuth and OIDC flows MFA protects and how to test them, authentication flaws that MFA is meant to prevent, and testing MFA flows across mobile viewports and device types.


MFA Testing Coverage Matrix

MFA Type Test Strategy Setup Complexity Reliability
Email OTP Mailpit/Mailhog API Low High
TOTP (Authenticator) Generate from seed Low High
SMS OTP Provider test numbers Medium High
Magic Link Mailbox API + URL extraction Low High
Hardware key (WebAuthn) Playwright WebAuthn mock High Medium
Backup codes Database seeding Low High

The most impactful recommendation: use a dedicated test email domain and a programmable local mailbox (Mailpit is free and trivial to spin up with Docker) so your test suite owns the email delivery pipeline end-to-end.

Validate your full auth flow including MFA automatically: Try ScanlyApp free and run scheduled login-and-authenticate checks that verify your entire authentication pipeline.

Related Posts