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.
