🎉 Limited Time Offer: 30% OFF First Month!

Use code at checkout - Valid on all plans

View Plans

🌐 Custom Domains Now Available!

Receive emails at [email protected] — available on all paid plans

Learn More
Back to Blog
Testing14 min read

Automate OTP Testing in Playwright Using a Disposable Email API

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:

  1. 1Trigger the OTP email (login, signup, or password reset form)
  2. 2Wait for it to arrive in a real mailbox
  3. 3Open the email and extract the code
  4. 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:

pseudocode
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:

e2e/helpers/inbox.ts
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:

e2e/tests/otp-login.spec.ts
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:

e2e/tests/password-reset.spec.ts
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:

.github/workflows/e2e.yml
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=chromium

That'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.