Skip to main content

How to Test Magic Links and Passwordless Login

Short answer

Magic links depend on email delivery, single-use tokens, expiry windows, and device handoff—submitting the form is not proof a session exists. Use disposable inboxes (Mailtrap, Mailosaur), extract token params to avoid bot prefetch, and probe Assert on /api/me—not shared inboxes or waitForTimeout after send.

Part of Testing Guides by auth and identity.

Who this is for

Teams shipping magic link, email OTP, or passwordless login (Stytch, Supabase Magic Link, Auth0 Passwordless, Firebase email link, WorkOS, Clerk email codes) who need Playwright E2E that captures real emails in CI, covers expiry/ reuse, and validates session boundaries.

Typical stacks: Next.js + Resend/SendGrid + custom tokens, Auth0 passwordless email, Supabase signInWithOtp, Stytch Email Magic Links.

Passwordless auth fails in ways password forms do not:

  • Revenue loss — magic link works but never binds to org/tenant; invite-only product allows any inbox to register.
  • Security incidents — links reusable until clicked (should be single-use); token in URL logged in referrer analytics; 7-day expiry but token valid forever server-side.
  • Support load — email client link prefetch consumes token before user clicks; "link expired" with no resend cooldown tested; mobile email app opens in-app browser losing session cookie.
  • Compliance exposure — magic link sent to wrong address due to typo with no confirmation step; audit cannot prove link was single-use.

E2E must assert email sent → token validated → session created → token invalidated on reuse—not only that "Check your email" appears.

Complexity map

ScenarioEdge caseWhy tests breakApproach
Email captureNo inbox in CISkip entire flowMailtrap/Mailosaur API
Bot prefetchScanner GET consumes link"Already used" for userExtract token; POST verify API
Link expiry15-minute windowFlaky if slow CIShort expiry + clock fixture OR Admin expire
Single-use reuseClick link twiceSecond should failNavigate twice; probe no session
Wrong emailTypo domainEnumeration leakAssert generic message; probe no user
Rate limit resendSpam resend button429 untestedRapid resend; assert cooldown
Deep link mobileOpens app not browserUntested handoffDocument manual; probe token API
PKCE / state in linkMissing bindingOpen redirectAssert state matches session
Cross-deviceRequest on desktop, open on phoneSession on wrong deviceToken-only exchange spec
Logged-in user requests linkShould not switch accountsAccount hijack edgeRequest while logged in
Custom email templateBroken URL in templateProd-only failureInbox test catches href
Unicode emailPlus addressingDuplicate usersuser+tag@domain probe
IP bindingLink valid only same IPMobile network flakeDocument policy; negative test
Concurrent requestsTwo links outstandingOnly newest validSend twice; test both tokens
Stytch/Supabase SDKDifferent token param namesCopy-paste wrong parserVendor-specific extractors
SSR cookie after linkClient says logged in, API 401Cookie domain mismatchProbe HTTP immediately

Tools for email capture

ToolUse caseDocs
MailtrapSandbox SMTP + Email Testing APIMailtrap API
MailosaurDedicated test email addressesMailosaur API
Ethereal EmailQuick Nodemailer dev inboxDev-only, not CI-stable

Wire your staging mail provider to Mailtrap in CI—never send magic links to real user domains in automated runs.

See transactional email testing for inbox patterns shared with verification and reset flows.

import { MailtrapClient } from 'mailtrap';

const mailtrap = new MailtrapClient({ token: process.env.MAILTRAP_API_TOKEN! });
const inboxId = Number(process.env.MAILTRAP_INBOX_ID);

test('magic link login establishes session', async ({ page, request }) => {
const email = `e2e-${Date.now()}@inbox.mailtrap.io`;

await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: 'Send magic link' }).click();
await expect(page.getByText('Check your email')).toBeVisible();

// Poll Mailtrap for message (avoid fixed sleep)
const message = await expect.poll(async () => {
const messages = await mailtrap.testing.messages.get(inboxId);
return messages.find(m => m.to_email === email);
}, { timeout: 30_000 }).toBeTruthy();

const html = message!.html_body;
const match = html.match(/href="([^"]+\/auth\/verify\?[^"]+)"/);
expect(match).toBeTruthy();

// Prefer extracting token to avoid prefetch issues
const verifyUrl = new URL(match![1]);
const token = verifyUrl.searchParams.get('token');

await page.goto(`/auth/verify?token=${token}`);
await page.waitForURL('/dashboard');

const me = await request.get('/api/me');
expect(me.status()).toBe(200);
expect((await me.json()).email).toBe(email);
});

Email security scanners GET links before users click. Mitigations:

  1. POST verification — link opens page with button "Confirm login" that POSTs token
  2. Extract token via API — navigate programmatically with token from Mailtrap (still tests server validation)
  3. Single-page confirm — require user interaction before token exchange

For Firebase/Stytch, call vendor verify endpoint directly with extracted oobCode or token (Stytch testing docs).

Expiry and negative paths

test('expired magic link does not create session', async ({ page, request }) => {
const email = `expired-${Date.now()}@inbox.mailtrap.io`;
const { token } = await request.post('/api/test/mint-magic-token', {
data: { email, expiresInSeconds: -60 },
}).then(r => r.json());

await page.goto(`/auth/verify?token=${token}`);
await expect(page.getByText(/expired|invalid/i)).toBeVisible();
expect((await request.get('/api/me')).status()).toBe(401);
});

Use a test-only mint route with controllable expiry—do not wait 15 minutes in CI. For clock-based expiry, see session timeout guide for Playwright clock patterns.

test('magic link is single-use', async ({ page }) => {
const verifyUrl = await sendAndExtractMagicLink(page, uniqueEmail);
await page.goto(verifyUrl);
await page.waitForURL('/dashboard');

await page.context().clearCookies();
await page.goto(verifyUrl); // second use
await expect(page.getByText(/invalid|already used/i)).toBeVisible();
});

Wrong / tampered token

Assert generic error copy (no "user not found" enumeration) and probe /api/me 401.

Rate limiting and resend UX

Passwordless products rely on resend when email is delayed:

ActionAssert
First send200; email in inbox
Resend within cooldownButton disabled or 429
Resend after cooldownNew token; old token invalidated (if policy)
Max sends per hourLockout message; probe no session

Use Playwright clock to advance time for cooldown specs:

await page.clock.install({ time: new Date('2025-01-15T12:00:00Z') });
// ... request link
await page.clock.fastForward('00:06:00'); // past 5-min cooldown
await page.getByRole('button', { name: 'Resend' }).click();

Provider-specific notes

Supabase sends OTP links with token_hash and type=email. Use Supabase local dev or Mailtrap with your SMTP config. For CI speed, use service role to generate session in Arrange for post-login tests.

Auth0 Passwordless

Auth0 sends email code or magic link depending on connection config. Use Auth0 test tenant and Passwordless OTP API for Arrange when not testing email template.

Stytch

Stytch provides test API keys and methods to authenticate tokens directly in test—use for unit/integration; keep one inbox E2E for template validation.

Arrange vs Act split

Test goalArrangeActAssert
Email template URLReal sendParse inboxhref contains correct domain
Post-login featuresMint session via test APINavigate appFeature probes
Expiry UXMint expired tokenOpen linkError + 401
Resend cooldownClock + first sendResendSecond email arrives

Most PR specs should use Arrange session; reserve full inbox path for 2–3 integration specs per release.

CI checklist

  1. Mailtrap/Mailosaur API token in CI secrets
  2. Unique @inbox.mailtrap.io or Mailosaur address per test
  3. Poll inbox with expect.poll, not fixed sleeps
  4. Extract tokens when prefetch is a risk
  5. Test mint route for expiry/reuse without waiting
  6. Probe /api/me after every happy path
  7. Staging SMTP routed to sandbox—never prod mail in CI

Anti-patterns

Anti-patternWhy it failsBetter approach
test@gmail.com shared inboxParallel collisionsUnique Mailtrap address
Click raw link from HTML alwaysPrefetch consumes tokenPOST confirm or API verify
Skip expired link testProd support ticketsMint expired token
Assert "Check your email" onlyNo session proofProbe /api/me
15-minute waitForTimeoutCI timeoutClock or mint route
Real SendGrid in CISends to real addressesMailtrap SMTP
Ignore resend cooldownAbuse vectorRate limit spec

Example scenario

Situation: User requests magic link; corporate email scanner prefetches the URL before the user clicks.

Expected outcome: Link remains valid for user confirmation—or POST-confirm step prevents prefetch consumption.

Why UI-only automation breaks: Test extracts link from Mailtrap and navigates directly—passes—but prod users see 'link already used'.

  1. Arrange: Send real magic link to Mailtrap inbox (matches prod email path).
  2. Act: Simulate prefetch with raw GET to link URL, then user POST-confirm or second navigation.
  3. Assert: If prefetch consumes token: user sees clear resend UX and new link works. Prefer design where GET shows confirm page without exchanging token.

TestChimp workflow: Instrument magic_link_sent and magic_link_verified with auth_flow_type; compare prod passwordless share vs test.

Same Arrange/Act/Assert pattern as expired-coupon checkout.

Connect scenarios to your QA workflow

Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).

External references

Frequently asked questions

How do I capture magic link emails in Playwright CI?

Route staging SMTP to Mailtrap or use Mailosaur test addresses. Poll the inbox API with expect.poll after triggering send—never use a shared Gmail inbox. Parse HTML for href or token param.

Why do magic links work in tests but fail for real users?

Email security scanners prefetch GET links and consume single-use tokens. Tests that navigate directly from Mailtrap hide this. Add POST-confirm step or test prefetch simulation; see HeroScenario pattern.

How do I test link expiry without waiting 15 minutes?

Add a test-only route that mints tokens with expiresInSeconds, including negative values for expired tokens. Alternatively use Playwright clock if expiry is client-display only—but server expiry needs mint route.

Should every E2E test request a new magic link?

No. Full inbox tests are slow (5–15s). Use inbox path for 2–3 release integration specs; use session mint Arrange for feature specs. TrueCoverage ensures passwordless slice is not zero.

How do I test magic link rate limiting?

Trigger multiple sends rapidly and assert cooldown UI or 429 from API. Use Playwright clock.fastForward to pass cooldown and confirm second email arrives with new token.

We added passwordless login—how much test coverage is enough?

At minimum: happy path inbox, expired token, reused token, resend cooldown. Compare auth_flow_type in prod vs test via TrueCoverage; if magic link exceeds 20% of sign-ins, inbox tests belong in PR or nightly CI.

Can TestChimp help maintain passwordless scenarios?

Yes—/testchimp init scaffolds Mailtrap-friendly probes and seed routes; evolve adds expiry and prefetch edge cases when TrueCoverage shows rising passwordless share. Link SmartTests with // @Scenario: for traceability to security requirements.

Apply these patterns in your repo

Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.

Start free on TestChimp · Book a demo