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.
Why testing magic links matters
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
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Email capture | No inbox in CI | Skip entire flow | Mailtrap/Mailosaur API |
| Bot prefetch | Scanner GET consumes link | "Already used" for user | Extract token; POST verify API |
| Link expiry | 15-minute window | Flaky if slow CI | Short expiry + clock fixture OR Admin expire |
| Single-use reuse | Click link twice | Second should fail | Navigate twice; probe no session |
| Wrong email | Typo domain | Enumeration leak | Assert generic message; probe no user |
| Rate limit resend | Spam resend button | 429 untested | Rapid resend; assert cooldown |
| Deep link mobile | Opens app not browser | Untested handoff | Document manual; probe token API |
| PKCE / state in link | Missing binding | Open redirect | Assert state matches session |
| Cross-device | Request on desktop, open on phone | Session on wrong device | Token-only exchange spec |
| Logged-in user requests link | Should not switch accounts | Account hijack edge | Request while logged in |
| Custom email template | Broken URL in template | Prod-only failure | Inbox test catches href |
| Unicode email | Plus addressing | Duplicate users | user+tag@domain probe |
| IP binding | Link valid only same IP | Mobile network flake | Document policy; negative test |
| Concurrent requests | Two links outstanding | Only newest valid | Send twice; test both tokens |
| Stytch/Supabase SDK | Different token param names | Copy-paste wrong parser | Vendor-specific extractors |
| SSR cookie after link | Client says logged in, API 401 | Cookie domain mismatch | Probe HTTP immediately |
Tools for email capture
| Tool | Use case | Docs |
|---|---|---|
| Mailtrap | Sandbox SMTP + Email Testing API | Mailtrap API |
| Mailosaur | Dedicated test email addresses | Mailosaur API |
| Ethereal Email | Quick Nodemailer dev inbox | Dev-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.
Happy path: request link → inbox → session
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);
});
Avoid bot prefetch consuming links
Email security scanners GET links before users click. Mitigations:
- POST verification — link opens page with button "Confirm login" that POSTs token
- Extract token via API — navigate programmatically with token from Mailtrap (still tests server validation)
- 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
Expired link
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.
Reused link
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:
| Action | Assert |
|---|---|
| First send | 200; email in inbox |
| Resend within cooldown | Button disabled or 429 |
| Resend after cooldown | New token; old token invalidated (if policy) |
| Max sends per hour | Lockout 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 Magic Link
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 goal | Arrange | Act | Assert |
|---|---|---|---|
| Email template URL | Real send | Parse inbox | href contains correct domain |
| Post-login features | Mint session via test API | Navigate app | Feature probes |
| Expiry UX | Mint expired token | Open link | Error + 401 |
| Resend cooldown | Clock + first send | Resend | Second email arrives |
Most PR specs should use Arrange session; reserve full inbox path for 2–3 integration specs per release.
CI checklist
- Mailtrap/Mailosaur API token in CI secrets
- Unique
@inbox.mailtrap.ioor Mailosaur address per test - Poll inbox with
expect.poll, not fixed sleeps - Extract tokens when prefetch is a risk
- Test mint route for expiry/reuse without waiting
- Probe
/api/meafter every happy path - Staging SMTP routed to sandbox—never prod mail in CI
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
test@gmail.com shared inbox | Parallel collisions | Unique Mailtrap address |
| Click raw link from HTML always | Prefetch consumes token | POST confirm or API verify |
| Skip expired link test | Prod support tickets | Mint expired token |
| Assert "Check your email" only | No session proof | Probe /api/me |
15-minute waitForTimeout | CI timeout | Clock or mint route |
| Real SendGrid in CI | Sends to real addresses | Mailtrap SMTP |
| Ignore resend cooldown | Abuse vector | Rate 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'.
- Arrange: Send real magic link to Mailtrap inbox (matches prod email path).
- Act: Simulate prefetch with raw GET to link URL, then user POST-confirm or second navigation.
- 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).
Related scenarios
- Firebase Authentication — email link sign-in
- OAuth social login — alternative sign-in methods
- MFA / 2FA — step-up after passwordless
- Transactional email — inbox infrastructure
- CAPTCHA flows — bot protection on send-link form
- Session timeout — passwordless session lifetime
External references
- Mailtrap Email Testing API
- Mailosaur documentation
- Stytch E2E testing
- Supabase passwordless email
- Auth0 passwordless
- Firebase email link auth
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.