{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreigzmjzd5g5obyvnuk7pp3xaa2pdfpz5keczlsdstgkpyt4g7t64vi",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mp7wjbdi6js2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreidgecj5uhadelbesmwoudqrs3elh2psqcfx45nrrf5c7tazyebwbu"
    },
    "mimeType": "image/webp",
    "size": 118190
  },
  "path": "/zerodrop/otp-verification-in-playwright-without-regex-5ep2",
  "publishedAt": "2026-06-26T21:47:37.000Z",
  "site": "https://dev.to",
  "tags": [
    "playwright",
    "testing",
    "typescript",
    "webdev",
    "zerodrop.dev",
    "npm",
    "GitHub Action",
    "@playwright"
  ],
  "textContent": "Most guides to OTP testing in Playwright include a function that looks something like this:\n\n\n\n    function extractOtp(emailBody: string): string {\n      const patterns = [\n        /\\b(\\d{6})\\b/,\n        /code[:\\s]+(\\d{4,8})/i,\n        /verification[:\\s]+(\\d{4,8})/i,\n        /OTP[:\\s]+(\\d{4,8})/i,\n      ];\n      for (const pattern of patterns) {\n        const match = emailBody.match(pattern);\n        if (match) return match[1];\n      }\n      throw new Error('OTP not found in email body');\n    }\n\n\nThis 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.\n\nThere is a better way.\n\n##  The Problem with Regex OTP Extraction\n\nWhen your app sends a verification email, the OTP is buried somewhere in the HTML body. To extract it you need to:\n\n  1. Fetch the raw email body\n  2. Parse HTML or plain text\n  3. Apply regex patterns that match your specific email format\n  4. Handle edge cases — 4-digit vs 6-digit codes, codes in tables, codes in buttons\n\n\n\nEvery 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.\n\nThe right place to extract the OTP is at the infrastructure layer — before the email even reaches your test suite.\n\n##  How ZeroDrop Extracts OTPs at the Edge\n\nZeroDrop 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.\n\nBy the time your test calls `waitForLatest()`, the OTP is already extracted and sitting in `email.otp`. No regex. No HTML parsing. No maintenance.\n\n\n\n    const email = await mail.waitForLatest(inbox);\n    email.otp // \"847291\" — already extracted\n\n\n##  Setup\n\n\n    npm install zerodrop-client\n\n\nNo API key. No signup. No environment variables.\n\n##  Basic OTP Test\n\n\n    import { test, expect } from '@playwright/test';\n    import { ZeroDrop } from 'zerodrop-client';\n\n    const mail = new ZeroDrop();\n\n    test('OTP email verification', async ({ page }) => {\n      const inbox = mail.generateInbox();\n\n      // Trigger the OTP email\n      await page.goto('/signup');\n      await page.fill('[name=\"email\"]', inbox);\n      await page.click('[type=\"submit\"]');\n\n      // Wait for the email — OTP auto-extracted, no regex needed\n      const email = await mail.waitForLatest(inbox, { timeout: 15000 });\n\n      expect(email.otp).toBeTruthy();\n      expect(email.otp).toMatch(/^\\d{4,8}$/);\n\n      // Enter the OTP\n      await page.fill('[name=\"otp\"]', email.otp!);\n      await page.click('[type=\"submit\"]');\n\n      await expect(page).toHaveURL('/dashboard');\n    });\n\n\nThat is the complete test. No regex. No helper function. No HTML parsing.\n\n##  Testing Digit-by-Digit OTP Inputs\n\nSome auth providers — Clerk, Auth0, Supabase — render OTP inputs as individual digit fields. `email.otp` is a plain string, so splitting it is trivial:\n\n\n\n    test('digit-by-digit OTP input', async ({ page }) => {\n      const inbox = mail.generateInbox();\n\n      await page.goto('/signup');\n      await page.fill('[name=\"email\"]', inbox);\n      await page.click('[type=\"submit\"]');\n\n      const email = await mail.waitForLatest(inbox, { timeout: 15000 });\n\n      // Split OTP into individual digits\n      const digits = email.otp!.split('');\n\n      for (let i = 0; i < digits.length; i++) {\n        await page.fill(`[name=\"otp-${i}\"]`, digits[i]);\n      }\n\n      await expect(page).toHaveURL('/dashboard');\n    });\n\n\n##  Clerk-Specific Pattern\n\nClerk renders a single input that accepts all digits at once but advances focus automatically:\n\n\n\n    test('Clerk OTP verification', async ({ page }) => {\n      const inbox = mail.generateInbox();\n\n      await page.goto('/sign-up');\n      await page.fill('input[name=\"identifier\"]', inbox);\n      await page.click('button:has-text(\"Continue\")');\n\n      const email = await mail.waitForLatest(inbox, { timeout: 15000 });\n\n      // Clerk: fill the first digit input, it auto-advances\n      await page.locator('input[name=\"code-0\"]').waitFor();\n      await page.fill('input[name=\"code-0\"]', email.otp!);\n\n      await expect(page).toHaveURL('/dashboard');\n    });\n\n\n##  Filtering by Sender When Multiple Emails Land\n\nIf your signup flow sends multiple emails — welcome email, verification email — use `filter` to target the right one:\n\n\n\n    test('OTP from specific sender', async ({ page }) => {\n      const inbox = mail.generateInbox();\n\n      await page.goto('/signup');\n      await page.fill('[name=\"email\"]', inbox);\n      await page.click('[type=\"submit\"]');\n\n      // Only catch the verification email, not the welcome email\n      const email = await mail.waitForLatest(inbox, {\n        timeout: 15000,\n        filter: {\n          from: 'noreply@yourapp.com',\n          hasOtp: true,\n        }\n      });\n\n      await page.fill('[name=\"otp\"]', email.otp!);\n      await page.click('[type=\"submit\"]');\n\n      await expect(page).toHaveURL('/dashboard');\n    });\n\n\n##  Password Reset OTP Flow\n\n\n    test('password reset OTP', async ({ page }) => {\n      const inbox = mail.generateInbox();\n\n      await page.goto('/forgot-password');\n      await page.fill('[name=\"email\"]', inbox);\n      await page.click('[type=\"submit\"]');\n\n      const email = await mail.waitForLatest(inbox, { timeout: 15000 });\n\n      // Navigate to reset page with OTP\n      await page.fill('[name=\"reset-code\"]', email.otp!);\n      await page.fill('[name=\"new-password\"]', 'NewPassword123!');\n      await page.click('[type=\"submit\"]');\n\n      await expect(page.locator('.success-message')).toBeVisible();\n    });\n\n\n##  Parallel Test Runs — No Collisions\n\n`generateInbox()` runs locally with no network request. Each parallel worker gets a unique inbox automatically:\n\n\n\n    test.describe.configure({ mode: 'parallel' });\n\n    test('user A OTP', async ({ page }) => {\n      const inbox = mail.generateInbox(); // unique per worker\n      // ...\n    });\n\n    test('user B OTP', async ({ page }) => {\n      const inbox = mail.generateInbox(); // different inbox, zero collision\n      // ...\n    });\n\n\n50 parallel workers. 50 isolated inboxes. Zero race conditions. No shared inbox collisions.\n\n##  GitHub Actions CI\n\n\n    name: E2E Tests\n\n    on: [push, pull_request]\n\n    jobs:\n      test:\n        runs-on: ubuntu-latest\n        steps:\n          - uses: actions/checkout@v4\n          - uses: actions/setup-node@v4\n            with:\n              node-version: 20\n          - run: npm ci\n          - run: npx playwright install --with-deps\n          - run: npx playwright test\n\n\nNo Docker. No SMTP service. No API keys in CI secrets. ZeroDrop works out of the box with no configuration.\n\n##  What Gets Auto-Extracted\n\nZeroDrop detects OTP codes near common labels in the email body:\n\n  * Labels: `code`, `otp`, `pin`, `verification`, `passcode`, `token`\n  * Format: 4-8 digit numeric sequences\n  * Scope: plain-text body (more reliable than HTML parsing)\n\n\n\n\n    email.otp        // \"847291\" — 4-8 digit code, or null if not detected\n    email.magicLink  // \"https://app.com/verify?token=...\" — or null\n    email.subject    // \"Your verification code\"\n    email.body       // Full plain-text body — available if you need it\n\n\n`email.otp` is `null` if no OTP pattern is detected. Always check before using:\n\n\n\n    expect(email.otp).not.toBeNull();\n    await page.fill('[name=\"otp\"]', email.otp!);\n\n\n##  Conclusion\n\nOTP 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.\n\n`email.otp` is just there. No regex. No helper functions. No maintenance.\n\nFree to use. No signup required. Works in CI out of the box.\n\n→ zerodrop.dev · npm · GitHub Action",
  "title": "OTP Verification in Playwright Without Regex"
}