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;
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:
- Catch the email
- Extract the magic link URL
- Navigate to it
- 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
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');
});
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();
});
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');
});
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();
});
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
// ...
});
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
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
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)