DEV Community

Cover image for How to Test Magic Link Authentication in Playwright (No API Key, No Regex)
zerodrop
zerodrop

Posted on

How to Test Magic Link Authentication in Playwright (No API Key, No Regex)

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.

The Traditional APIs work. But they require an API key, a paid account, and this kind of code to extract the link:

// The old way — manual regex on raw HTML
const link = message.html?.links?.[0]?.href;
Enter fullscreen mode Exit fullscreen mode

There's a cleaner way.

The Problem with Magic Link Testing

When a user clicks "Send magic link," your app generates a signed token and emails it. The test needs to:

  1. Catch the email
  2. Extract the magic link URL
  3. Navigate to it
  4. Assert the user is authenticated

Step 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.

ZeroDrop does all of that at the edge before the email reaches your test. email.magicLink is just there — ready to use.

Setup

npm install zerodrop-client
Enter fullscreen mode Exit fullscreen mode

No API key. No account. No environment variables.

Basic Magic Link Test

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

const mail = new ZeroDrop();

test('magic link login', async ({ page }) => {
  // Generate a unique inbox for this test
  const inbox = mail.generateInbox();

  // Request a magic link
  await page.goto('/login');
  await page.fill('[name="email"]', inbox);
  await page.click('button:has-text("Send magic link")');

  // Wait for the email — arrives in under 1 second via SSE
  const email = await mail.waitForLatest(inbox, { timeout: 15000 });

  // magicLink is auto-extracted from the email body — no regex needed
  expect(email.magicLink).toBeTruthy();

  // Navigate to the magic link
  await page.goto(email.magicLink!);

  // Assert the user is now authenticated
  await expect(page).toHaveURL('/dashboard');
  await expect(page.locator('h1')).toContainText('Welcome');
});
Enter fullscreen mode Exit fullscreen mode

That's the complete test. No HTML parsing. No link extraction. No regex.

Testing Password Reset via Magic Link

Password reset flows work the same way — a signed link is emailed to the user:

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

  // Trigger password reset
  await page.goto('/forgot-password');
  await page.fill('[name="email"]', inbox);
  await page.click('button:has-text("Send reset link")');

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

  // Navigate to the reset link
  await page.goto(email.magicLink!);

  // Set a new password
  await page.fill('[name="password"]', 'NewSecurePassword123!');
  await page.fill('[name="confirmPassword"]', 'NewSecurePassword123!');
  await page.click('[type="submit"]');

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

Testing Magic Link Expiry

Magic links should expire. Test that too:

test('expired magic link shows error', async ({ page }) => {
  const inbox = mail.generateInbox();

  await page.goto('/login');
  await page.fill('[name="email"]', inbox);
  await page.click('button:has-text("Send magic link")');

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

  // Tamper with the token to simulate expiry
  const expiredLink = email.magicLink!.replace(/token=[^&]+/, 'token=expired-token-xyz');
  await page.goto(expiredLink);

  // Assert the app handles expired links gracefully
  await expect(page.locator('.error-message')).toContainText('link has expired');
});
Enter fullscreen mode Exit fullscreen mode

Testing One-Time Use

Magic links should be single-use. Here's how to verify that:

test('magic link is single-use', async ({ page, context }) => {
  const inbox = mail.generateInbox();

  await page.goto('/login');
  await page.fill('[name="email"]', inbox);
  await page.click('button:has-text("Send magic link")');

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

  // First use — should succeed
  await page.goto(magicLink);
  await expect(page).toHaveURL('/dashboard');

  // Second use — should fail
  const page2 = await context.newPage();
  await page2.goto(magicLink);
  await expect(page2.locator('.error-message')).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Parallel Test Runs — No Collisions

Because generateInbox() runs locally with no network request, parallel workers each get a unique inbox automatically:

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

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

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

10 parallel workers. 10 isolated inboxes. Zero race conditions.

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 vs Traditional APIs for Magic Link Testing

Traditional APIs ZeroDrop
Price $40/mo Free
API key required
Magic link extraction Manual regex Auto-extracted
Setup time ~30 mins ~5 mins
Parallel-safe
CI secrets needed

How magicLink Extraction Works

ZeroDrop 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.

The extracted URL is stored as magicLink on the email object. If no matching URL is found, magicLink is null.

const email = await mail.waitForLatest(inbox);

email.magicLink  // "https://app.com/auth/verify?token=abc123xyz"
                 // or null if no magic link detected
Enter fullscreen mode Exit fullscreen mode

Conclusion

Testing 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.

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


Next Steps

  • 🚀 Get Started: Check out the documentation at zerodrop.dev
  • 📦 Install the Package: Grab the client directly on npm
  • 🛠️ Automate CI: Drop the official GitHub Action straight into your workflow.

Top comments (0)