Two-factor authentication is table-stakes security in 2026. But testing OTP flows in end-to-end tests is a notorious pain point: you need a real email address, a way to read the email programmatically, and the OTP code extracted in time for the 15-second expiry window. This guide shows you how to do all of that with Playwright and the OpenInbox API — in TypeScript, from scratch, finishing in under 30 seconds per test run.
Why Email OTP Testing Is Painful
Most 2FA implementations send a 6-digit code via email. From a test automation perspective, this means your Playwright test must:
- 1Trigger the OTP email (login, signup, or password reset form)
- 2Wait for it to arrive in a real mailbox
- 3Open the email and extract the code
- 4Enter the code back into the browser before it expires
The usual workarounds don't hold up in production test suites:
Disabling OTP in test mode
You're no longer testing the real flow. If the OTP email template breaks, you won't know until a user complains.
Hardcoded test codes
Requires backend changes, bypasses rate limiting, and the test diverges from real behavior.
IMAP polling on Gmail
Slow, unreliable, and Gmail frequently blocks automated logins. Not CI-friendly.
Manual testing
Doesn't scale. You can't run it in CI, and it burns 5 minutes every time.
The Solution: Disposable Inboxes + Playwright
The approach is simple: before each test, create a fresh disposable inbox via the OpenInbox REST API. Use that email address in the form, wait for the OTP to arrive, read it via the API, and type it into the page. The inbox auto-deletes after one hour, so there's no cleanup.
Here's the high-level flow:
inbox = POST /api/v1/inboxes → { id, email }
form = page.fill('[name=email]', inbox.email)
submit = page.click('button[type=submit]')
otp = poll GET /api/v1/inboxes/:id/emails → extract code
enter = page.fill('[data-testid=otp]', otp)
assert = page.waitForURL('/dashboard') → success ✓Step 1 — Create the API Helper
This TypeScript module wraps the OpenInbox API with a clean interface for Playwright tests:
import { request } from '@playwright/test';
const API_BASE = 'https://openinbox.io/api/v1';
export async function createTestInbox() {
const ctx = await request.newContext({
extraHTTPHeaders: {
'X-API-Key': process.env.OPENINBOX_API_KEY!,
},
});
const res = await ctx.post(`${API_BASE}/inboxes`);
const json = await res.json();
return { ctx, inbox: json.data as { id: string; email: string } };
}
export async function waitForOtp(
ctx: Awaited<ReturnType<typeof request.newContext>>,
inboxId: string,
timeoutMs = 30_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await ctx.get(`${API_BASE}/inboxes/${inboxId}/emails`);
const json = await res.json();
if (json.data.length > 0) {
// Fetch the full email to get the body
const emailRes = await ctx.get(
`${API_BASE}/emails/${json.data[0].id}`,
);
const emailJson = await emailRes.json();
const body = emailJson.data.textBody || '';
// Extract 4-8 digit OTP code
const match = body.match(/\b(\d{4,8})\b/);
if (match) return match[1];
// Fallback: return the preview for manual parsing
throw new Error(
`Email arrived but no OTP code found in body: ${body.substring(0, 200)}`,
);
}
await new Promise((r) => setTimeout(r, 2_000));
}
throw new Error(`No email received within ${timeoutMs}ms`);
}The helper uses @playwright/test's built-in request API context, so it reuses Playwright's networking stack — including proxy settings, retries, and timeouts.
Step 2 — Write the OTP Login Test
Here's the complete Playwright test. It logs into an app that sends a 6-digit email OTP:
import { test, expect } from '@playwright/test';
import { createTestInbox, waitForOtp } from '../helpers/inbox';
test.describe('OTP Login Flow', () => {
test('should log in with email OTP', async ({ page }) => {
// 1. Create a disposable inbox
const { ctx, inbox } = await createTestInbox();
console.log(`Using inbox: ${inbox.email}`);
// 2. Navigate to the login page
await page.goto('https://staging.yourapp.com/login');
// 3. Enter the email address and request OTP
await page.fill('[name="email"]', inbox.email);
await page.click('button:has-text("Send Code")');
// Wait for the "Code sent" confirmation
await expect(
page.getByText('Check your email'),
).toBeVisible({ timeout: 10_000 });
// 4. Poll the inbox for the OTP
const otp = await waitForOtp(ctx, inbox.id);
console.log(`Received OTP: ${otp}`);
// 5. Enter the OTP into each digit field
const otpDigits = otp.split('');
for (let i = 0; i < otpDigits.length; i++) {
await page.fill(`[data-testid="otp-${i}"]`, otpDigits[i]);
}
// 6. Submit and wait for redirect
await page.click('button:has-text("Verify")');
await page.waitForURL('**/dashboard', { timeout: 15_000 });
// 7. Assert we're logged in
await expect(page.getByTestId('user-menu')).toBeVisible();
console.log('OTP login test passed ✓');
});
test('should sign up and verify email with OTP', async ({ page }) => {
const { ctx, inbox } = await createTestInbox();
await page.goto('https://staging.yourapp.com/register');
// Fill the signup form
await page.fill('[name="name"]', 'E2E Test User');
await page.fill('[name="email"]', inbox.email);
await page.fill('[name="password"]', 'TestPass123!');
await page.click('button:has-text("Create Account")');
// Wait for the verification code
const otp = await waitForOtp(ctx, inbox.id);
// Enter the verification code
await page.fill('[data-testid="verification-code"]', otp);
await page.click('button:has-text("Verify")');
// Assert account is created and verified
await page.waitForURL('**/onboarding');
await expect(page.getByText('Welcome')).toBeVisible();
});
});Anatomy of the Test
Inbox creation
POST /api/v1/inboxes returns a unique address like [email protected]. Each test gets its own inbox, so parallel test runs never collide — a critical requirement for CI/CD where multiple jobs may run simultaneously.
OTP extraction
The helper fetches the full email via GET /api/v1/emails/:emailId and runs a regex \b(\d{4,8})\b against the text body. This catches 4, 6, and 8-digit OTP codes. If your app sends codes in a specific format (e.g., Your code is: 482910), narrow the regex for robustness.
Timing
Most OTP emails arrive within 2–5 seconds. The polling loop checks every 2 seconds with a 30-second timeout. In a typical CI environment, the entire test completes in 10–20 seconds. Compare that to the 3+ minutes it takes to manually check email on a phone.
Test isolation
Because each test creates a unique inbox, you can run tests in parallel with npx playwright test --workers=4. No shared state, no race conditions, no flaky failures from email leftovers in a shared mailbox.
Bonus: Password Reset Flow
The same pattern works for password reset flows. Here's a concise variant that extracts a reset link instead of an OTP:
import { test, expect } from '@playwright/test';
import { createTestInbox } from '../helpers/inbox';
test('should reset password via email link', async ({ page }) => {
const { ctx, inbox } = await createTestInbox();
// Navigate to password reset page
await page.goto('https://staging.yourapp.com/forgot-password');
await page.fill('[name="email"]', inbox.email);
await page.click('button:has-text("Send Reset Link")');
// Poll for the reset email
let resetUrl = '';
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const res = await ctx.get(
`https://openinbox.io/api/v1/inboxes/${inbox.id}/emails`,
);
const json = await res.json();
if (json.data.length > 0) {
const emailRes = await ctx.get(
`https://openinbox.io/api/v1/emails/${json.data[0].id}`,
);
const emailJson = await emailRes.json();
const match = emailJson.data.textBody.match(
/https?:\/\/[^\s]*reset[^\s]*/i,
);
if (match) {
resetUrl = match[0];
break;
}
}
await new Promise((r) => setTimeout(r, 2_000));
}
expect(resetUrl).toBeTruthy();
// Visit reset link and set new password
await page.goto(resetUrl);
await page.fill('[name="password"]', 'NewSecurePass123!');
await page.fill('[name="confirmPassword"]', 'NewSecurePass123!');
await page.click('button:has-text("Reset Password")');
await expect(page.getByText('Password updated')).toBeVisible();
});Running in CI
Add the API key as a repository secret and reference it in your workflow:
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run OTP E2E tests
env:
OPENINBOX_API_KEY: ${{ secrets.OPENINBOX_API_KEY }}
run: npx playwright test e2e/tests/ --project=chromiumThat's it. Every push triggers a full OTP login test against your staging environment, using a fresh inbox that's never been used before. See our CI/CD guide for more advanced patterns including matrix builds and parallel sharding.
Tips for Bulletproof OTP Tests
Match your OTP format exactly
If your codes are always 6 digits, use /\b(\d{6})\b/ instead of the generic 4-8 digit regex. Fewer false positives in emails that contain order IDs or timestamps.
Handle multiple emails gracefully
If your app sends a welcome email AND an OTP email, filter by subject line (e.g., subject.includes("code")) before extracting the OTP.
Test the expiry case
Write a separate test that waits until the OTP expires, then asserts that submitting it shows the correct error message. This catches expiry logic bugs.
Use Playwright's trace viewer for debugging
Enable tracing on failure. Combined with console.log of the inbox email and OTP, the trace gives you everything you need to debug a flaky run without re-running.
Don't hardcode timeouts
Use environment variables for poll timeout and interval. In CI, you might want 45 seconds; locally, 15 is enough. Keep it configurable.
Automate every OTP flow in your app
Create a free account, copy the helper above, and have your first OTP test running in 5 minutes. No credit card, no SMTP setup, no shared Gmail accounts.