DEV Community

Cover image for OTP Verification in Playwright Without Regex
zerodrop
zerodrop

Posted on

OTP Verification in Playwright Without Regex

Most guides to OTP testing in Playwright include a function that looks something like this:

function extractOtp(emailBody: string): string {
  const patterns = [
    /\b(\d{6})\b/,
    /code[:\s]+(\d{4,8})/i,
    /verification[:\s]+(\d{4,8})/i,
    /OTP[:\s]+(\d{4,8})/i,
  ];
  for (const pattern of patterns) {
    const match = emailBody.match(pattern);
    if (match) return match[1];
  }
  throw new Error('OTP not found in email body');
}
Enter fullscreen mode Exit fullscreen mode

This function is fragile. It breaks when the email template changes. It returns false positives when the email body contains order IDs or timestamps. It requires you to maintain regex patterns for every email provider your app might use.

There is a better way.

The Problem with Regex OTP Extraction

When your app sends a verification email, the OTP is buried somewhere in the HTML body. To extract it you need to:

  1. Fetch the raw email body
  2. Parse HTML or plain text
  3. Apply regex patterns that match your specific email format
  4. Handle edge cases — 4-digit vs 6-digit codes, codes in tables, codes in buttons

Every time your email provider changes their template, your regex breaks. Every time you add a new auth provider, you write new patterns. It is maintenance overhead that compounds forever.

The right place to extract the OTP is at the infrastructure layer — before the email even reaches your test suite.

How ZeroDrop Extracts OTPs at the Edge

ZeroDrop catches emails at Cloudflare's edge before storing them. When an email arrives, the worker runs OTP detection on the body and stores the result as a structured field alongside the raw email.

By the time your test calls waitForLatest(), the OTP is already extracted and sitting in email.otp. No regex. No HTML parsing. No maintenance.

const email = await mail.waitForLatest(inbox);
email.otp // "847291" — already extracted
Enter fullscreen mode Exit fullscreen mode

Setup

npm install zerodrop-client
Enter fullscreen mode Exit fullscreen mode

No API key. No signup. No environment variables.

Basic OTP Test

import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';

const mail = new ZeroDrop();

test('OTP email verification', async ({ page }) => {
  const inbox = mail.generateInbox();

  // Trigger the OTP email
  await page.goto('/signup');
  await page.fill('[name="email"]', inbox);
  await page.click('[type="submit"]');

  // Wait for the email — OTP auto-extracted, no regex needed
  const email = await mail.waitForLatest(inbox, { timeout: 15000 });

  expect(email.otp).toBeTruthy();
  expect(email.otp).toMatch(/^\d{4,8}$/);

  // Enter the OTP
  await page.fill('[name="otp"]', email.otp!);
  await page.click('[type="submit"]');

  await expect(page).toHaveURL('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

That is the complete test. No regex. No helper function. No HTML parsing.

Testing Digit-by-Digit OTP Inputs

Some auth providers — Clerk, Auth0, Supabase — render OTP inputs as individual digit fields. email.otp is a plain string, so splitting it is trivial:

test('digit-by-digit OTP input', async ({ page }) => {
  const inbox = mail.generateInbox();

  await page.goto('/signup');
  await page.fill('[name="email"]', inbox);
  await page.click('[type="submit"]');

  const email = await mail.waitForLatest(inbox, { timeout: 15000 });

  // Split OTP into individual digits
  const digits = email.otp!.split('');

  for (let i = 0; i < digits.length; i++) {
    await page.fill(`[name="otp-${i}"]`, digits[i]);
  }

  await expect(page).toHaveURL('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

Clerk-Specific Pattern

Clerk renders a single input that accepts all digits at once but advances focus automatically:

test('Clerk OTP verification', async ({ page }) => {
  const inbox = mail.generateInbox();

  await page.goto('/sign-up');
  await page.fill('input[name="identifier"]', inbox);
  await page.click('button:has-text("Continue")');

  const email = await mail.waitForLatest(inbox, { timeout: 15000 });

  // Clerk: fill the first digit input, it auto-advances
  await page.locator('input[name="code-0"]').waitFor();
  await page.fill('input[name="code-0"]', email.otp!);

  await expect(page).toHaveURL('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

Filtering by Sender When Multiple Emails Land

If your signup flow sends multiple emails — welcome email, verification email — use filter to target the right one:

test('OTP from specific sender', async ({ page }) => {
  const inbox = mail.generateInbox();

  await page.goto('/signup');
  await page.fill('[name="email"]', inbox);
  await page.click('[type="submit"]');

  // Only catch the verification email, not the welcome email
  const email = await mail.waitForLatest(inbox, {
    timeout: 15000,
    filter: {
      from: 'noreply@yourapp.com',
      hasOtp: true,
    }
  });

  await page.fill('[name="otp"]', email.otp!);
  await page.click('[type="submit"]');

  await expect(page).toHaveURL('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

Password Reset OTP Flow

test('password reset OTP', async ({ page }) => {
  const inbox = mail.generateInbox();

  await page.goto('/forgot-password');
  await page.fill('[name="email"]', inbox);
  await page.click('[type="submit"]');

  const email = await mail.waitForLatest(inbox, { timeout: 15000 });

  // Navigate to reset page with OTP
  await page.fill('[name="reset-code"]', email.otp!);
  await page.fill('[name="new-password"]', 'NewPassword123!');
  await page.click('[type="submit"]');

  await expect(page.locator('.success-message')).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Parallel Test Runs — No Collisions

generateInbox() runs locally with no network request. Each parallel worker gets a unique inbox automatically:

test.describe.configure({ mode: 'parallel' });

test('user A OTP', async ({ page }) => {
  const inbox = mail.generateInbox(); // unique per worker
  // ...
});

test('user B OTP', async ({ page }) => {
  const inbox = mail.generateInbox(); // different inbox, zero collision
  // ...
});
Enter fullscreen mode Exit fullscreen mode

50 parallel workers. 50 isolated inboxes. Zero race conditions. No shared inbox collisions.

GitHub Actions CI

name: E2E Tests

on: [push, pull_request]

jobs:
  test:
    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
      - run: npx playwright test
Enter fullscreen mode Exit fullscreen mode

No Docker. No SMTP service. No API keys in CI secrets. ZeroDrop works out of the box with no configuration.

What Gets Auto-Extracted

ZeroDrop detects OTP codes near common labels in the email body:

  • Labels: code, otp, pin, verification, passcode, token
  • Format: 4-8 digit numeric sequences
  • Scope: plain-text body (more reliable than HTML parsing)
email.otp        // "847291" — 4-8 digit code, or null if not detected
email.magicLink  // "https://app.com/verify?token=..." — or null
email.subject    // "Your verification code"
email.body       // Full plain-text body — available if you need it
Enter fullscreen mode Exit fullscreen mode

email.otp is null if no OTP pattern is detected. Always check before using:

expect(email.otp).not.toBeNull();
await page.fill('[name="otp"]', email.otp!);
Enter fullscreen mode Exit fullscreen mode

Conclusion

OTP extraction belongs at the infrastructure layer, not in your test suite. When the extraction happens at the edge — before the email is stored — your tests never need to touch the raw email body.

email.otp is just there. No regex. No helper functions. No maintenance.

Free to use. No signup required. Works in CI out of the box.

zerodrop.dev · npm · GitHub Action

Top comments (0)