playwright

Playwright Magic Link Testing — The Complete Guide (2026)

12 min read

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:

  1. Backend bypass. Patch auth to accept a known token when it sees a magic string. Fastest. Also a security bug waiting to ship.
  2. Database read. Query the DB for the token after sending, build the URL yourself, navigate. Robust for internal auth, useless for third-party.
  3. 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:

  1. +clerk_test addresses. Clerk auto-fills a fixed OTP but doesn't emit a real magic-link URL. Useless for magic-link testing specifically.
  2. 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:

  1. 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.
  2. 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

PropertyBackend bypassDatabase readEmail 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 time10 min30 min15 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:

  1. Read the SDK quick start — 10 minutes to first passing test
  2. Go with Approach 3 (email interception) unless you have a specific reason not to
  3. 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.

Grab a free API key →

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.