Testing 2FA and OTP Flows in Playwright
Two-factor authentication is the wall most end-to-end suites slam into: the login form submits, an OTP screen appears, and your test has no inbox, no SMS, and no authenticator app to read a code from. In this guide you will learn practical, production-tested patterns for Playwright 2FA OTP testing in TypeScript, including how to generate valid TOTP codes in-test, intercept the OTP API, reuse authenticated sessions with storage state, and handle time-based expiry without flaky waits.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
Why 2FA breaks naive end-to-end tests
A normal login test types a username and password and presses submit. With 2FA in the mix, the application demands a second factor that is, by design, only available to the legitimate user: a time-based one-time password (TOTP) from an authenticator app, an SMS or email OTP, or a push notification. Test automation cannot read a real SMS or a real Gmail inbox reliably, and even if it could, the codes expire in 30 to 60 seconds, which makes timing fragile.
The good news is that you almost never need to test the third-party delivery channel itself. Twilio and your email provider have their own tests. What you need to verify is that your application accepts a valid code, rejects an invalid one, enforces expiry, and rate-limits brute force. That reframing unlocks several clean, deterministic strategies.
Choosing a strategy: a quick comparison
Pick the approach that matches what your test actually owns. If your test controls the TOTP secret, generate codes locally. If the backend offers a test hook, read the code from there. Only fall back to real inbox polling for a small smoke suite.
| Strategy | Best for | Speed | Flakiness |
|---|---|---|---|
| Generate TOTP in-test (otplib) | Authenticator-app 2FA where you own the seed | Fast | Very low |
| Read OTP from a test API / DB hook | SMS/email OTP with a staging backdoor | Fast | Low |
| Intercept network with page.route | Asserting the request payload or stubbing delivery | Fast | Low |
| Poll a real mailbox (Mailosaur/MailSlurp) | End-to-end smoke over the real channel | Slow | Medium |
| Reuse storageState, skip 2FA entirely | Every test after login is already proven | Fastest | Very low |
Pattern 1: Generate a valid TOTP code in your test
When the app uses an authenticator app (Google Authenticator, Authy, 1Password), the server stores a shared secret and both sides compute the same 6-digit code from the current time using the RFC 6238 algorithm. If your test environment seeds a known secret for the test user, you can compute the exact same code in Node with the otplib library. This is the single most reliable way to do Playwright 2FA OTP testing for TOTP flows.
Install the dependency first:
npm install otplib
Then compute the code at the moment you need it. Generating it inline (not at file load) matters because TOTP rotates every 30 seconds.
import { test, expect } from '@playwright/test';
import { authenticator } from 'otplib';
// The same base32 secret the backend seeded for this test account.
const TOTP_SECRET = process.env.TOTP_SECRET!;
test('logs in with a freshly generated TOTP code', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('qa-totp@example.com');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
// The 2FA screen is now showing. Generate the code at this exact moment.
const code = authenticator.generate(TOTP_SECRET);
await page.getByLabel('Authentication code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
One subtle trap: if the code is generated within the last second or two of its 30-second window, it may expire between generation and submission. Guard against that by checking the remaining time and waiting for the next window when you are too close to the edge.
import { authenticator } from 'otplib';
async function safeTotp(secret: string): Promise<string> {
const remaining = authenticator.timeRemaining(); // seconds left in window
if (remaining < 3) {
// Too close to rotation; wait for a fresh window to avoid a race.
await new Promise((r) => setTimeout(r, (remaining + 1) * 1000));
}
return authenticator.generate(secret);
}
Pattern 2: Read SMS/email OTP from a test hook
SMS and email OTPs are random codes the server generates and stores, then sends through a provider. You cannot compute them, but a well-built staging environment exposes a way to read the last issued code: a protected test-only endpoint, a database query, or a fake SMS provider sandbox. Wrap that read in Playwright’s request fixture so it shares cookies and base URL with your page.
import { test, expect } from '@playwright/test';
test('logs in using an OTP fetched from the staging test hook', async ({ page, request }) => {
await page.goto('/login');
await page.getByLabel('Phone').fill('+15555550123');
await page.getByRole('button', { name: 'Send code' }).click();
await expect(page.getByText('We sent a code')).toBeVisible();
// Test-only endpoint, guarded behind an env flag on staging.
const res = await request.get('/api/test-support/latest-otp', {
params: { phone: '+15555550123' },
headers: { 'x-test-secret': process.env.TEST_SUPPORT_SECRET! },
});
expect(res.ok()).toBeTruthy();
const { code } = await res.json();
await page.getByLabel('Verification code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
Never ship such an endpoint to production. Gate it behind an environment variable and a shared secret, and confirm it 404s in prod with its own test.
Pattern 3: Intercept the OTP request with page.route
Sometimes you do not want real delivery at all. Maybe you are testing the frontend in isolation, or asserting that the verify request sends the right payload. Playwright’s page.route lets you intercept, inspect, and fulfill or modify network calls. Below we stub the “send code” response so no SMS goes out, and we assert the body of the verification request.
import { test, expect } from '@playwright/test';
test('verifies the OTP request payload without sending a real SMS', async ({ page }) => {
// Stub the delivery endpoint so nothing is actually sent.
await page.route('**/api/otp/send', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ sent: true, channel: 'sms' }),
});
});
// Capture and assert the verify payload, then let it through (continue).
let verifiedCode: string | undefined;
await page.route('**/api/otp/verify', async (route) => {
const payload = route.request().postDataJSON();
verifiedCode = payload.code;
await route.continue();
});
await page.goto('/login');
await page.getByLabel('Phone').fill('+15555550123');
await page.getByRole('button', { name: 'Send code' }).click();
await page.getByLabel('Verification code').fill('123456');
await page.getByRole('button', { name: 'Verify' }).click();
expect(verifiedCode).toBe('123456');
});
You can also fully fake the verify response to test UI states you cannot easily trigger otherwise, such as a locked account or an expired code, by fulfilling with the relevant status and error body instead of calling route.continue().
Pattern 4: Test code expiry deterministically with page.clock
OTP codes expire. A flaky way to test that is to sleep for 60 real seconds; a clean way is Playwright’s page.clock API, which lets you install a controllable clock and fast-forward time. The UI countdown timer and any client-side expiry logic advance instantly, so a 5-minute expiry test runs in milliseconds.
import { test, expect } from '@playwright/test';
test('shows an expiry message after the OTP window passes', async ({ page }) => {
// Install the clock at a fixed start time before any app script runs.
await page.clock.install({ time: new Date('2026-06-27T10:00:00Z') });
await page.goto('/login');
await page.getByLabel('Phone').fill('+15555550123');
await page.getByRole('button', { name: 'Send code' }).click();
await expect(page.getByText(/code expires in/i)).toBeVisible();
// Jump 5 minutes into the future; the countdown and expiry fire instantly.
await page.clock.fastForward('05:00');
await expect(page.getByText(/code has expired/i)).toBeVisible();
await expect(page.getByRole('button', { name: 'Resend code' })).toBeEnabled();
});
A caveat worth knowing: page.clock controls the browser’s clock, not your Node process or your server. If your TOTP secret approach (Pattern 1) relies on real wall-clock time matching the server, do not install a fake clock for those tests, or the generated code will no longer match what the backend computes. Reserve page.clock for client-side timing behavior.
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Pattern 5: Authenticate once, reuse the session everywhere
The fastest 2FA test is the one you do not repeat. Once you have proven login-with-2FA works in a dedicated test, run it a single time in a setup project, save the authenticated browser state to disk with storageState, and have every other test load that state. They start already logged in and skip the OTP screen entirely.
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import { authenticator } from 'otplib';
const authFile = 'playwright/.auth/user.json';
setup('authenticate through 2FA once', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('qa-totp@example.com');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
const code = authenticator.generate(process.env.TOTP_SECRET!);
await page.getByLabel('Authentication code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
Wire it up in playwright.config.ts so the setup runs as a dependency and the main project consumes the saved state:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
Now hundreds of tests skip the 2FA dance. Only the setup project ever touches the OTP flow, so a code change in login breaks one obvious place instead of your whole suite.
Covering the negative paths
A complete 2FA test suite is not just the happy path. Make sure you cover the cases attackers and clumsy users actually hit. Use expect.soft when you want to assert several independent error conditions in one test without bailing on the first failure.
- Wrong code rejected: submit
000000and assert an inline error plus that the URL stays on the verify step. - Expired code rejected: generate a code, fast-forward server time in a backend test or use a stubbed expired response.
- Rate limiting: submit several wrong codes and assert the account locks or a captcha appears.
- Resend works: click resend and confirm a new code is issued and the old one is invalidated.
- Backup codes: verify single-use recovery codes log the user in and cannot be reused.
import { test, expect } from '@playwright/test';
test('rejects an invalid OTP and keeps the user on the verify step', async ({ page }) => {
await page.goto('/verify-otp');
await page.getByLabel('Verification code').fill('000000');
await page.getByRole('button', { name: 'Verify' }).click();
await expect.soft(page.getByText(/invalid or expired code/i)).toBeVisible();
await expect.soft(page).toHaveURL(/\/verify-otp/);
await expect.soft(page.getByRole('heading', { name: 'Dashboard' })).toBeHidden();
});
Keeping secrets and CI safe
TOTP seeds and test-support secrets are credentials. Read them from environment variables, never hardcode them, and inject them through your CI provider’s secret store. Use a dedicated throwaway test account with its own seed, isolated from any real user. Restrict test-only OTP endpoints to non-production environments and assert that fact. With these guardrails, your Playwright 2FA OTP testing stays both deterministic and secure.
FAQ
Can Playwright read a real SMS or email OTP automatically?
Not directly. Playwright drives a browser, not a phone or mailbox. To read a real code you integrate an email/SMS testing service such as Mailosaur or MailSlurp and poll their API from your test, or you expose a guarded test-only endpoint on staging that returns the last issued code. For authenticator-app TOTP you skip delivery entirely and generate the code in-test with a library like otplib.
How do I avoid flaky tests when the OTP code expires mid-test?
Generate TOTP codes at the exact moment you submit them, not at file load, and check the time remaining in the current window; if fewer than about three seconds are left, wait for the next window before generating. For testing the expiry behavior itself, use Playwright’s page.clock API to fast-forward the browser clock instead of sleeping for real seconds.
Should every test go through the 2FA flow?
No. Test the full 2FA login once in a dedicated setup project, save the authenticated browser state with storageState, and have all other tests load that state so they start already signed in. This keeps your suite fast and means a login change breaks one obvious test instead of hundreds.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
