{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreibrckegwkn6hwrcx5wlimlsnhu35n4yebs3fm4lkfzjgszqhhamcy",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mp57cfwr2rk2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreidgecj5uhadelbesmwoudqrs3elh2psqcfx45nrrf5c7tazyebwbu"
    },
    "mimeType": "image/webp",
    "size": 118190
  },
  "path": "/zerodrop/how-to-test-magic-link-authentication-in-playwright-no-api-key-no-regex-5ac",
  "publishedAt": "2026-06-25T19:05:02.000Z",
  "site": "https://dev.to",
  "tags": [
    "playwright",
    "testing",
    "webdev",
    "typescript",
    "zerodrop.dev",
    "npm",
    "GitHub Action",
    "@playwright"
  ],
  "textContent": "Magic links are becoming the default authentication pattern for modern SaaS apps. Passwordless login, email verification, password resets — they all rely on a link sent to an inbox your test can't normally reach.\n\nThe Traditional APIs work. But they require an API key, a paid account, and this kind of code to extract the link:\n\n\n\n    // The old way — manual regex on raw HTML\n    const link = message.html?.links?.[0]?.href;\n\n\nThere's a cleaner way.\n\n##  The Problem with Magic Link Testing\n\nWhen a user clicks \"Send magic link,\" your app generates a signed token and emails it. The test needs to:\n\n  1. Catch the email\n  2. Extract the magic link URL\n  3. Navigate to it\n  4. Assert the user is authenticated\n\n\n\nStep 2 is where most teams give up. The link is buried in HTML. You need to parse the email body, find the right `<a>` tag, extract the `href`, handle edge cases where the URL is wrapped or truncated.\n\nZeroDrop does all of that at the edge before the email reaches your test. `email.magicLink` is just there — ready to use.\n\n##  Setup\n\n\n    npm install zerodrop-client\n\n\nNo API key. No account. No environment variables.\n\n##  Basic Magic Link 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('magic link login', async ({ page }) => {\n      // Generate a unique inbox for this test\n      const inbox = mail.generateInbox();\n\n      // Request a magic link\n      await page.goto('/login');\n      await page.fill('[name=\"email\"]', inbox);\n      await page.click('button:has-text(\"Send magic link\")');\n\n      // Wait for the email — arrives in under 1 second via SSE\n      const email = await mail.waitForLatest(inbox, { timeout: 15000 });\n\n      // magicLink is auto-extracted from the email body — no regex needed\n      expect(email.magicLink).toBeTruthy();\n\n      // Navigate to the magic link\n      await page.goto(email.magicLink!);\n\n      // Assert the user is now authenticated\n      await expect(page).toHaveURL('/dashboard');\n      await expect(page.locator('h1')).toContainText('Welcome');\n    });\n\n\nThat's the complete test. No HTML parsing. No link extraction. No regex.\n\n##  Testing Password Reset via Magic Link\n\nPassword reset flows work the same way — a signed link is emailed to the user:\n\n\n\n    test('password reset via magic link', async ({ page }) => {\n      const inbox = mail.generateInbox();\n\n      // Trigger password reset\n      await page.goto('/forgot-password');\n      await page.fill('[name=\"email\"]', inbox);\n      await page.click('button:has-text(\"Send reset link\")');\n\n      // Catch the reset email\n      const email = await mail.waitForLatest(inbox, { timeout: 15000 });\n\n      // Navigate to the reset link\n      await page.goto(email.magicLink!);\n\n      // Set a new password\n      await page.fill('[name=\"password\"]', 'NewSecurePassword123!');\n      await page.fill('[name=\"confirmPassword\"]', 'NewSecurePassword123!');\n      await page.click('[type=\"submit\"]');\n\n      // Assert success\n      await expect(page.locator('.success-message')).toBeVisible();\n    });\n\n\n##  Testing Magic Link Expiry\n\nMagic links should expire. Test that too:\n\n\n\n    test('expired magic link shows error', async ({ page }) => {\n      const inbox = mail.generateInbox();\n\n      await page.goto('/login');\n      await page.fill('[name=\"email\"]', inbox);\n      await page.click('button:has-text(\"Send magic link\")');\n\n      const email = await mail.waitForLatest(inbox, { timeout: 15000 });\n\n      // Tamper with the token to simulate expiry\n      const expiredLink = email.magicLink!.replace(/token=[^&]+/, 'token=expired-token-xyz');\n      await page.goto(expiredLink);\n\n      // Assert the app handles expired links gracefully\n      await expect(page.locator('.error-message')).toContainText('link has expired');\n    });\n\n\n##  Testing One-Time Use\n\nMagic links should be single-use. Here's how to verify that:\n\n\n\n    test('magic link is single-use', async ({ page, context }) => {\n      const inbox = mail.generateInbox();\n\n      await page.goto('/login');\n      await page.fill('[name=\"email\"]', inbox);\n      await page.click('button:has-text(\"Send magic link\")');\n\n      const email = await mail.waitForLatest(inbox, { timeout: 15000 });\n      const magicLink = email.magicLink!;\n\n      // First use — should succeed\n      await page.goto(magicLink);\n      await expect(page).toHaveURL('/dashboard');\n\n      // Second use — should fail\n      const page2 = await context.newPage();\n      await page2.goto(magicLink);\n      await expect(page2.locator('.error-message')).toBeVisible();\n    });\n\n\n##  Parallel Test Runs — No Collisions\n\nBecause `generateInbox()` runs locally with no network request, parallel workers each get a unique inbox automatically:\n\n\n\n    test.describe.configure({ mode: 'parallel' });\n\n    test('user A magic link', async ({ page }) => {\n      const inbox = mail.generateInbox(); // unique per worker\n      // ...\n    });\n\n    test('user B magic link', async ({ page }) => {\n      const inbox = mail.generateInbox(); // different inbox, no collision\n      // ...\n    });\n\n\n10 parallel workers. 10 isolated inboxes. Zero race conditions.\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.\n\n##  ZeroDrop vs Traditional APIs for Magic Link Testing\n\n| Traditional APIs | ZeroDrop\n---|---|---\nPrice | $40/mo | Free\nAPI key required | ✓ | ✗\nMagic link extraction | Manual regex | Auto-extracted\nSetup time | ~30 mins | ~5 mins\nParallel-safe | ✓ | ✓\nCI secrets needed | ✓ | ✗\n\n##  How magicLink Extraction Works\n\nZeroDrop parses the raw HTML payload at Cloudflare's edge before storing it. Our worker engines identify authentication patterns (verify, confirm, reset, token, activate) and isolate the primary call-to-action.\n\nThe extracted URL is stored as `magicLink` on the email object. If no matching URL is found, `magicLink` is `null`.\n\n\n\n    const email = await mail.waitForLatest(inbox);\n\n    email.magicLink  // \"https://app.com/auth/verify?token=abc123xyz\"\n                     // or null if no magic link detected\n\n\n##  Conclusion\n\nTesting magic link authentication doesn't require a paid email testing service, an API key, or manual HTML parsing. ZeroDrop extracts the link automatically at the edge — your test just reads `email.magicLink` and navigates to it.\n\nFree to use. No signup required. Works in CI out of the box.\n\n###  Next Steps\n\n  * 🚀 **Get Started:** Check out the documentation at zerodrop.dev\n  * 📦 **Install the Package:** Grab the client directly on npm\n  * 🛠️ **Automate CI:** Drop the official GitHub Action straight into your workflow.\n\n",
  "title": "How to Test Magic Link Authentication in Playwright (No API Key, No Regex)"
}