playwright
Playwright Magic Link Testing — The Complete Guide (2026)
Magic links are lovely to use and awful to test.
There's no page.fill('[name=magic-link]', ???). The URL is in a real email in a real inbox somewhere, and your test can't open Gmail. You've been staring at this problem for the last 40 minutes.
This is the whole guide to solving it. Every real approach that works in 2026, across every major auth stack, ordered from "quick hack" to "actually correct."
Specifically testing OTPs (6-digit codes)? Read the Playwright OTP testing guide instead. Same shape, different tradeoffs.
Three ways to test magic links. That's it.
Every guide, tutorial, and Stack Overflow answer you'll find falls into one of these three buckets:
- Backend bypass. Patch auth to accept a known token when it sees a magic string. Fastest. Also a security bug waiting to ship.
- Database read. Query the DB for the token after sending, build the URL yourself, navigate. Robust for internal auth, useless for third-party.
- Email interception. Catch the outgoing email at the delivery layer, pull the URL out, navigate. The only one that tests the real production flow end-to-end.
Which you pick depends on your auth setup and how much you enjoy pager duty. Let's walk them.
Approach 1: Backend bypass (fast, dangerous)
Add a test-only code path that returns a known token when the email matches:
// server/auth.ts — production code with a test carve-out
export async function generateMagicLink(email: string) {
if (process.env.NODE_ENV === "test" && email.endsWith("+e2e@yourapp.com")) {
return `https://yourapp.com/auth/verify?token=STATIC_TEST_TOKEN`;
}
return generateRealToken(email);
}
Test:
test("magic link sign-in (bypass)", async ({ page }) => {
await page.goto("/signin");
await page.fill("input[name=email]", "test+e2e@yourapp.com");
await page.click("text=Send magic link");
await page.goto("/auth/verify?token=STATIC_TEST_TOKEN");
await expect(page).toHaveURL("/dashboard");
});
Pros. Fast. No external deps. Deterministic.
Cons. You just shipped a bypass. Miss one deploy of NODE_ENV=production and there's a token that permanently works. This has happened, at real companies, more than once. If the file with the carve-out is anywhere near your real auth code — don't.
Approach 2: Database read (Auth.js, Better Auth, DIY backends)
If you own the DB, your test can just query it after the "send" step. Auth.js stores tokens in VerificationToken. Better Auth has similar tables.
import { test, expect } from "@playwright/test";
import { prisma } from "@/lib/db";
test("magic link sign-in (DB read)", async ({ page }) => {
const email = `test-${Date.now()}@yourapp.com`;
await page.goto("/signin");
await page.fill("input[name=email]", email);
await page.click("text=Send magic link");
// Wait for the token to be persisted 🤞
await page.waitForTimeout(500);
const record = await prisma.verificationToken.findFirst({
where: { identifier: email },
orderBy: { expires: "desc" },
});
expect(record).not.toBeNull();
// Auth.js verification URL pattern
const url = new URL(`${process.env.BASE_URL}/api/auth/callback/email`);
url.searchParams.set("token", record!.token);
url.searchParams.set("email", email);
await page.goto(url.toString());
await expect(page).toHaveURL("/dashboard");
});
Pros. No third-party service. Actually tests real token verification.
Cons. Doesn't test the delivery layer at all. Your Resend integration could be broken and this test still passes. That waitForTimeout(500) is a race waiting to happen (production DBs have replication lag). And every auth-lib upgrade that touches token schema is going to break this.
Fits: internal microservices where you already have DB access. Breaks: third-party auth (Clerk, Auth0, Supabase).
Approach 3: Email interception (production parity)
This is the one that actually tests the whole thing — token generation, email content, URL structure, delivery path. Works with every auth stack because every auth stack ultimately calls some function to send the email.
The move: intercept that function's output instead of reading the DB or bypassing prod code.
The pattern in one snippet
import { test, expect } from "@playwright/test";
import { SandboxClient } from "@otpmagiclink/playwright";
const sandbox = new SandboxClient({
apiKey: process.env.OTP_API_KEY!,
});
test("magic link sign-in (email interception)", async ({ page }) => {
const email = `test-${Date.now()}@yourapp.com`;
await page.goto("/signin");
await page.fill("input[name=email]", email);
await page.click("text=Send magic link");
await expect(page.getByText(/check your email/i)).toBeVisible();
// The URL that was actually in the email
const link = await sandbox.waitForMagicLink(email);
// Simulate the user clicking
await page.goto(link);
await expect(page).toHaveURL("/dashboard");
});
That's the pattern. Rest of this post is auth-stack-specific setup for the "route delivery to sandbox in test mode" bit. Once that's wired, every test looks like the snippet above.
Setup by auth stack
Auth.js (NextAuth v5)
Auth.js's email providers give you a sendVerificationRequest callback:
// auth.ts
import NextAuth from "next-auth";
import Resend from "next-auth/providers/resend";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Resend({
from: "auth@yourapp.com",
async sendVerificationRequest({ identifier, url, provider }) {
if (process.env.NODE_ENV === "test") {
await fetch(`${process.env.OTP_SANDBOX_URL}/api/v1/verifications`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OTP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
identifier,
channel: "EMAIL",
kind: "MAGIC_LINK",
redirectUrl: url,
}),
});
return;
}
await realResend.emails.send({ to: identifier /* ... */ });
},
}),
],
});
Better Auth
Same idea, different plugin:
// auth.ts
import { betterAuth } from "better-auth";
import { magicLink } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
magicLink({
expiresIn: 3600,
async sendMagicLink({ email, url }) {
if (process.env.NODE_ENV === "test") {
await fetch(`${process.env.OTP_SANDBOX_URL}/api/v1/verifications`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OTP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
identifier: email,
channel: "EMAIL",
kind: "MAGIC_LINK",
redirectUrl: url,
}),
});
return;
}
await resend.emails.send({ to: email /* ... */ });
},
}),
],
});
Full walkthrough: Testing Better Auth in Playwright.
Clerk
Clerk owns its email infrastructure, so there's no sendMagicLink you can hijack. Two options:
+clerk_testaddresses. Clerk auto-fills a fixed OTP but doesn't emit a real magic-link URL. Useless for magic-link testing specifically.- Custom email domain. Set one up in Clerk's dashboard, point its MX at the sandbox ingest. Every magic link Clerk sends to that domain gets captured.
Details in the Clerk testing guide.
Supabase Auth
Supabase uses its own SMTP by default. Two levers:
- Custom SMTP. In Supabase dashboard, set SMTP creds to point at Mailpit locally, or at the sandbox's SMTP ingest. Real Supabase-generated links flow through.
- Admin API bypass. Sign the user in server-side. Faster, tests less.
For production parity, go with custom SMTP:
// supabase.ts (test mode)
export const supabase = createClient(url, key, {
auth: {
redirectTo: "http://localhost:3000/auth/callback",
},
});
Set SMTP host to smtp.otpmagiclink.com (or your test catcher) in the Supabase dashboard. Every real magic-link email now flows through the sandbox.
Custom auth (Resend, Postmark, direct SMTP)
Rolling your own on top of Resend/Postmark? You've already got a sendMagicLinkEmail function somewhere. Wrap it:
// magicLink.ts
import { resend } from "@/lib/resend";
export async function sendMagicLinkEmail(email: string, url: string) {
if (process.env.NODE_ENV === "test") {
await fetch(`${process.env.OTP_SANDBOX_URL}/api/v1/verifications`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OTP_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
identifier: email,
channel: "EMAIL",
kind: "MAGIC_LINK",
redirectUrl: url,
}),
});
return;
}
await resend.emails.send({
to: email,
from: "auth@yourapp.com",
subject: "Your sign-in link",
html: `<a href="${url}">Click to sign in</a>`,
});
}
That's the whole change. Every test after this uses sandbox.waitForMagicLink(email).
Five test cases every magic-link flow should have
Once the infrastructure's wired, here's the checklist. These are the tests I've watched catch real bugs in real apps.
1. Happy path
test("sign in with magic link", async ({ page }) => {
const email = `happy-${Date.now()}@yourapp.com`;
await page.goto("/signin");
await page.fill("input[name=email]", email);
await page.click("text=Send magic link");
const link = await sandbox.waitForMagicLink(email);
await page.goto(link);
await expect(page).toHaveURL("/dashboard");
});
2. Expired link
test("expired magic link shows expired page", async ({ page }) => {
const email = `expired-${Date.now()}@yourapp.com`;
await page.goto("/signin");
await page.fill("input[name=email]", email);
await page.click("text=Send magic link");
const link = await sandbox.waitForMagicLink(email);
// Fast-forward past expiry
await sandbox.advanceClock(3601); // 1 hour + 1 second
await page.goto(link);
await expect(page.getByText(/link.*expired/i)).toBeVisible();
});
test.afterEach(async () => {
await sandbox.resetClock();
});
Nobody actually tests this, because "wait 1 hour" isn't a real option. Sandbox time-travel is the only way.
3. Single-use enforcement
test("magic link only works once", async ({ browser }) => {
const email = `single-${Date.now()}@yourapp.com`;
const contextA = await browser.newContext();
const pageA = await contextA.newPage();
await pageA.goto("/signin");
await pageA.fill("input[name=email]", email);
await pageA.click("text=Send magic link");
const link = await sandbox.waitForMagicLink(email);
// First use — success
await pageA.goto(link);
await expect(pageA).toHaveURL("/dashboard");
// Second use — should fail
const contextB = await browser.newContext();
const pageB = await contextB.newPage();
await pageB.goto(link);
await expect(pageB.getByText(/already used|invalid/i)).toBeVisible();
});
4. Cross-device isolation
test("magic link opens session in the clicking browser", async ({
browser,
}) => {
const email = `cross-${Date.now()}@yourapp.com`;
const requestContext = await browser.newContext();
const requestPage = await requestContext.newPage();
await requestPage.goto("/signin");
await requestPage.fill("input[name=email]", email);
await requestPage.click("text=Send magic link");
const link = await sandbox.waitForMagicLink(email);
// Completely separate browser clicks the link
const clickContext = await browser.newContext();
const clickPage = await clickContext.newPage();
await clickPage.goto(link);
await expect(clickPage).toHaveURL("/dashboard");
// Original context is still NOT logged in
await requestPage.reload();
await expect(requestPage).toHaveURL(/signin|home/);
});
Catches the "session landed on the wrong device" bug. Real security concern in magic-link systems.
5. Email swap attack
test("cannot redirect magic link URL to a different email", async ({
page,
}) => {
const email = `victim-${Date.now()}@yourapp.com`;
await page.goto("/signin");
await page.fill("input[name=email]", email);
await page.click("text=Send magic link");
const link = await sandbox.waitForMagicLink(email);
// Attacker rewrites the email query param
const attackerUrl = link.replace(email, "attacker@evil.com");
await page.goto(attackerUrl);
await expect(page).not.toHaveURL("/dashboard");
});
Auth.js has been vulnerable to this pattern historically if you didn't verify email + token together. This test catches it.
CI
# .github/workflows/e2e.yml
name: E2E
on: [push]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports: [5432:5432]
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
env:
NODE_ENV: test
OTP_API_KEY: ${{ secrets.OTP_API_KEY }}
OTP_SANDBOX_URL: https://otpmagiclink.com
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
NEXT_PUBLIC_APP_URL: http://localhost:3000
Nothing fancy. The only test-specific env vars route emails at the sandbox instead of Resend.
The three approaches, ranked
| Property | Backend bypass | Database read | Email interception |
|---|---|---|---|
| Tests real token generation | ❌ | ✅ | ✅ |
| Tests real email delivery layer | ❌ | ❌ | ✅ |
| Tests real magic-link URL format | ⚠️ Partial | ⚠️ Partial | ✅ |
| Works with Clerk / third-party | ❌ | ❌ | ✅ |
| Time-travel for expiry testing | ❌ | ❌ | ✅ |
| Production security risk | 🔴 High | 🟢 None | 🟢 None |
| Setup time | 10 min | 30 min | 15 min |
FAQ
Which auth libraries does this work with?
Anything that lets you replace or wrap the email-sending function. Which is every major library: Auth.js/NextAuth, Better Auth, Clerk (via custom domain), Supabase (via custom SMTP), Firebase Auth (via Cloud Function trigger), or any DIY flow.
Does this test the real email content?
Yes. The sandbox captures the exact HTML/text body sent. You can assert on subject, body, and URL structure:
const messages = await sandbox.getInbox(email);
expect(messages[0].subject).toContain("sign in");
expect(messages[0].body).toContain("Welcome back");
Can I use this without Playwright (e.g. Cypress)?
Yes. SandboxClient is a plain HTTP client — no Playwright dependency.
What about SMS-based magic links?
Same pattern — channel: 'SMS', sandbox.waitForOtp(phoneNumber). SMS magic links (numeric codes typed in) work exactly like email OTPs from a test's perspective.
Shared inbox risk like Mailinator?
No. The sandbox is scoped to your project's API key. Two tests using the same email address each see their own message stream. No races between parallel workers, no leakage between customers.
What does this cost at scale?
Free tier is 1 project with unlimited runs. Pro is $19/mo for unlimited projects and CI/CD support. Pricing.
What to do next
If you're standing this up for the first time:
- Read the SDK quick start — 10 minutes to first passing test
- Go with Approach 3 (email interception) unless you have a specific reason not to
- Add the five test cases above to your suite before magic-link auth ships to prod
The alternative is finding out from a user that magic links have been broken for a week.
Ship OTP + magic-link tests today
@otpmagiclink/playwright is a one-line SDK for waiting on OTPs and magic links in Playwright, Cypress, or any Node test runner. Free tier included.