Auth Testing Guide
How to Test Magic Link Login with Playwright: Complete 2026 Guide
A scenario-by-scenario walkthrough of testing passwordless magic link authentication with Playwright. Email interception, token expiry, single-use enforcement, cross-device verification, and the async timing issues that break real test suites.
“Passwordless login adoption has grown steadily, with nearly 68% of developers reporting they have shipped or are evaluating magic link or passkey flows in production applications.”
State of Auth 2025 Survey
1. Why Testing Magic Links Is the Hardest Auth Flow to Automate
Magic link login works by sending a one-time URL to the user's email address. The user clicks the link, the server verifies the token embedded in the URL, and the user gets a session. No password is ever involved. This sounds simple until you try to automate it.
There are five structural reasons magic link login is harder to test than password-based auth. First, email delivery is asynchronous. Your test submits the login form, and the email arrives somewhere between 200 milliseconds and 30 seconds later. There is no DOM event you can wait for. Second, you need a way to read the email programmatically inside your test. Playwright does not have a built-in email client. Third, the magic link token is single-use. If your test accidentally clicks it twice, the second navigation fails and the test gives you a confusing error about an invalid or expired token. Fourth, tokens expire, typically within 5 to 15 minutes. If your CI runner is slow or a previous test leaked state, the token may be dead before your test even reads it. Fifth, the link may include URL-encoded parameters, query strings, and hash fragments that break if your regex extraction is too naive.
A good magic link test suite addresses all five of these problems. The sections below walk through each scenario you need, with runnable Playwright TypeScript code and the equivalent Assrt plain-English scenario you can copy directly.
Magic Link Login Flow
User enters email
Login form
Server generates token
One-time use, 15 min TTL
Email sent
Contains magic URL
User clicks link
From inbox
Server verifies token
Check expiry + usage
Session created
User is logged in
Magic Link Authentication Sequence
2. Setting Up a Test Email Environment
Before you write a single magic link test, you need a way to capture and read emails programmatically. There are three common approaches, each suited to a different stage of your pipeline.
Email Capture Setup Checklist
- Inbucket: Built into Supabase local dev, zero config, REST API on port 54324
- Mailosaur: Dedicated test SMTP server with SDK polling (waitFor), ideal for CI/staging
- Mailtrap: Sandbox SMTP credentials, API for fetching by recipient, good for shared teams
- Unique email per test run: append Date.now() or UUID to the local part
- Polling with timeout: at least 30 seconds for real SMTP, 10 seconds for local
- HTML entity decoding: always replace & with & after link extraction
Option A: Inbucket (Supabase Local Dev)
If you use Supabase, the local development stack includes Inbucket as a built-in email capture server. Every email your Supabase instance sends lands in Inbucket instead of going to a real SMTP server. Inbucket exposes a REST API on port 54324 by default. This is the fastest option for local development because there is zero external setup.
Option B: Mailosaur
Mailosaur provides dedicated test email servers with a REST API for retrieving messages. You create a server in your Mailosaur dashboard, and any email sent to an address ending in@serverId.mailosaur.net is captured and queryable. This works well for CI environments where you need real SMTP delivery but cannot run Inbucket.
# .env.test
MAILOSAUR_API_KEY=your_api_key_here
MAILOSAUR_SERVER_ID=abc123de
APP_BASE_URL=http://localhost:3000Option C: Mailtrap
Mailtrap works similarly to Mailosaur. You point your app's SMTP configuration at Mailtrap's sandbox SMTP credentials, and all outbound emails are captured in the Mailtrap inbox. The Mailtrap API lets you fetch messages by recipient, extract HTML bodies, and parse links.
# .env.test (Mailtrap)
MAILTRAP_API_TOKEN=your_mailtrap_token
MAILTRAP_INBOX_ID=1234567
APP_BASE_URL=http://localhost:3000Shared Helper: Extract Magic Link from Email Body
Regardless of which email provider you use, you will need a helper function that extracts the magic link URL from the email HTML body. The key detail: magic links often contain URL-encoded query parameters, and some email clients wrap long URLs across multiple lines. Use a regex that handles both.
Token Verification Flow
Extract token from URL
Parse query string
Look up token in DB
Find matching record
Check expiry
Token TTL validation
Check usage count
Must be zero
Mark token used
Set used_at timestamp
Issue session
Set cookie or JWT
3. Scenario: Happy Path with Inbucket (Supabase Local Dev)
Happy Path: Inbucket Magic Link Login
ModerateThis is the foundational scenario. Request a magic link, poll the Inbucket API until the email arrives, extract the URL from the email body, navigate to it, and verify that the user is logged in with a valid session.
Goal
Starting from the login page, request a magic link for a test email, retrieve the email from Inbucket, click the link, and confirm the user lands on the authenticated dashboard.
Preconditions
- Supabase running locally with
supabase start - Inbucket accessible at
http://localhost:54324 - App running at
APP_BASE_URLconfigured for Supabase auth
Playwright Implementation
Assrt Equivalent
# scenarios/magic-link-happy-path-inbucket.assrt
describe: Magic link login happy path using Inbucket
given:
- I am on the login page
- Supabase is running locally with Inbucket
steps:
- fill the email field with a fresh test email
- click "Send magic link"
- wait for the "check your email" confirmation
- retrieve the latest email from Inbucket for that address
- extract the magic link URL from the email body
- navigate to the magic link URL
expect:
- I am redirected to /dashboard
- the page shows a dashboard heading
- a valid session cookie existsCompare: Playwright vs Assrt (Inbucket Magic Link)
import { test, expect } from '@playwright/test';
async function getLatestInbucketEmail(mailbox: string) {
const maxAttempts = 20;
const interval = 500;
for (let i = 0; i < maxAttempts; i++) {
const res = await fetch(
`http://localhost:54324/api/v1/mailbox/${mailbox}`
);
const messages = await res.json();
if (messages.length > 0) {
const latest = messages[messages.length - 1];
const detail = await fetch(
`http://localhost:54324/api/v1/mailbox/${mailbox}/${latest.id}`
);
return detail.json();
}
await new Promise(r => setTimeout(r, interval));
}
throw new Error('No email received');
}
test('magic link login via Inbucket', async ({ page }) => {
const email = `testuser+${Date.now()}@example.com`;
const mailbox = email.split('@')[0];
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: /send magic link/i }).click();
await expect(page.getByText(/check your email/i)).toBeVisible();
const emailData = await getLatestInbucketEmail(mailbox);
const linkMatch = emailData.body.html.match(
/href="([^"]*token=[^"]*)"/i
);
if (!linkMatch) throw new Error('Magic link not found');
const magicLink = linkMatch[1].replace(/&/g, '&');
await page.goto(magicLink);
await expect(page).toHaveURL(/\/dashboard/);
await expect(
page.getByRole('heading', { name: /dashboard/i })
).toBeVisible();
const cookies = await page.context().cookies();
expect(cookies.some(c => c.name.includes('session'))).toBe(true);
});4. Scenario: Happy Path with Mailosaur
Happy Path: Mailosaur Magic Link Login
ModerateMailosaur is the better choice when you need to test against a real SMTP pipeline, for example in staging or CI environments where Inbucket is not available. The Mailosaur Node SDK provides a waitFor method that handles the polling internally, which simplifies your test code compared to manual polling.
Playwright Implementation
Assrt Equivalent
# scenarios/magic-link-happy-path-mailosaur.assrt
describe: Magic link login happy path using Mailosaur
given:
- I am on the login page
- Mailosaur is configured with MAILOSAUR_API_KEY and MAILOSAUR_SERVER_ID
steps:
- fill the email field with a fresh Mailosaur test email
- click "Send magic link"
- wait for the "check your email" confirmation
- wait for the email to arrive in Mailosaur (30 second timeout)
- extract the magic link URL from the email HTML body
- navigate to the magic link URL
expect:
- I am redirected to /dashboard
- the page shows a dashboard heading5. Scenario: Expired Magic Link
Expired Token: Link Used After TTL
ComplexMagic link tokens have a time-to-live (TTL), usually between 5 and 15 minutes. If the user clicks the link after the token has expired, your app should show a clear error message and offer a way to request a new link. This scenario verifies that behavior.
Testing expiry in real time would mean waiting 15 minutes per test run. Instead, configure your test environment to use a short TTL (for example, 5 seconds) or directly manipulate the token's created_at timestamp in the database to simulate expiry.
Playwright Implementation
import { test, expect } from '@playwright/test';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
test('expired magic link shows error and offers retry', async ({ page }) => {
const email = `expired+${Date.now()}@example.com`;
const mailbox = email.split('@')[0];
// 1. Request the magic link
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: /send magic link/i }).click();
await expect(page.getByText(/check your email/i)).toBeVisible();
// 2. Retrieve the email and extract the magic link
const emailData = await getLatestInbucketEmail(mailbox);
const linkMatch = emailData.body.html.match(/href="([^"]*token=[^"]*)"/i);
const magicLink = linkMatch![1].replace(/&/g, '&');
// 3. Extract the token from the URL and expire it in the database
const url = new URL(magicLink);
const token = url.searchParams.get('token')!;
// Force-expire the token by backdating it
await supabase.rpc('expire_magic_link_token', {
token_hash: token,
expired_at: new Date(Date.now() - 3600_000).toISOString(),
});
// 4. Now navigate to the expired magic link
await page.goto(magicLink);
// 5. Assert the error state
await expect(page.getByText(/link has expired/i)).toBeVisible();
await expect(page.getByText(/request a new link/i)).toBeVisible();
// 6. The user should NOT have a session
await expect(page).not.toHaveURL(/\/dashboard/);
});Alternative: Short TTL in Test Config
If you cannot manipulate the database directly, configure your auth provider to use a very short TTL in the test environment. For Supabase, set GOTRUE_MAILER_OTP_EXP=5 (5 seconds) in your local Supabase config. Then simply wait for the TTL to pass before navigating.
test('expired magic link: short TTL approach', async ({ page }) => {
// ... request link and extract URL ...
// Wait for the short TTL to expire (5 seconds + buffer)
await page.waitForTimeout(6_000);
// Navigate to the now-expired link
await page.goto(magicLink);
await expect(page.getByText(/link has expired/i)).toBeVisible();
});Assrt Equivalent
# scenarios/magic-link-expired.assrt
describe: Expired magic link shows error
given:
- I am on the login page
- the magic link TTL is set to 5 seconds in test config
steps:
- fill the email field with a fresh test email
- click "Send magic link"
- retrieve the magic link from the email
- wait 6 seconds for the token to expire
- navigate to the magic link URL
expect:
- the page shows "link has expired" error
- the page offers a "request a new link" option
- I am NOT redirected to /dashboard6. Scenario: Already-Used Magic Link
Single-Use Enforcement: Link Clicked Twice
ModerateMagic link tokens must be single-use. If a user clicks the same link a second time (or if an attacker replays the URL), the server should reject it. This is a critical security property that is easy to get wrong and easy to verify with a test.
Playwright Implementation
test('magic link cannot be used twice', async ({ page, context }) => {
const email = `reuse+${Date.now()}@example.com`;
const mailbox = email.split('@')[0];
// 1. Request and extract the magic link
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: /send magic link/i }).click();
const emailData = await getLatestInbucketEmail(mailbox);
const linkMatch = emailData.body.html.match(/href="([^"]*token=[^"]*)"/i);
const magicLink = linkMatch![1].replace(/&/g, '&');
// 2. First click: should succeed
await page.goto(magicLink);
await expect(page).toHaveURL(/\/dashboard/);
// 3. Log out to clear the session
await page.goto('/logout');
// 4. Second click: should fail
await page.goto(magicLink);
await expect(page.getByText(/link has already been used|invalid.*token/i))
.toBeVisible();
await expect(page).not.toHaveURL(/\/dashboard/);
});Testing Replay in a Separate Browser Context
For a stricter test, open the used link in a completely fresh browser context with no cookies. This simulates an attacker who intercepted the URL and tries to use it from their own machine.
test('used magic link rejected in fresh context', async ({ page, browser }) => {
// ... request link, extract URL, use it once in 'page' ...
// Open a brand new context (no cookies, no state)
const freshContext = await browser.newContext();
const freshPage = await freshContext.newPage();
await freshPage.goto(magicLink);
await expect(freshPage.getByText(/link has already been used|invalid/i))
.toBeVisible();
await freshContext.close();
});Assrt Equivalent
# scenarios/magic-link-reuse.assrt
describe: Magic link cannot be used twice
given:
- I am on the login page
steps:
- fill the email field with a fresh test email
- click "Send magic link"
- retrieve the magic link from the email
- navigate to the magic link URL
- confirm I am on /dashboard
- log out
- navigate to the same magic link URL again
expect:
- the page shows "link has already been used" or "invalid token" error
- I am NOT on /dashboard7. Scenario: Magic Link Opens in Different Browser
Cross-Device: Link Opened in a New Browser Context
ComplexUsers frequently request a magic link on their phone but open the email on their laptop, or vice versa. The magic link must work regardless of which browser or device opens it, because the token is the authentication factor, not the session that requested it. Some implementations incorrectly bind the token to the requesting session, which causes this flow to fail silently.
Playwright Implementation
test('magic link works when opened in a different browser', async ({ browser }) => {
const email = `crossdevice+${Date.now()}@example.com`;
const mailbox = email.split('@')[0];
// Context A: the device that requested the magic link
const contextA = await browser.newContext();
const pageA = await contextA.newPage();
await pageA.goto('/login');
await pageA.getByLabel('Email').fill(email);
await pageA.getByRole('button', { name: /send magic link/i }).click();
await expect(pageA.getByText(/check your email/i)).toBeVisible();
// Retrieve the magic link from Inbucket
const emailData = await getLatestInbucketEmail(mailbox);
const linkMatch = emailData.body.html.match(/href="([^"]*token=[^"]*)"/i);
const magicLink = linkMatch![1].replace(/&/g, '&');
// Context B: a completely different browser (different device)
const contextB = await browser.newContext({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' +
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const pageB = await contextB.newPage();
// Open the magic link in Context B
await pageB.goto(magicLink);
// The user should be authenticated in Context B
await expect(pageB).toHaveURL(/\/dashboard/);
await expect(pageB.getByRole('heading', { name: /dashboard/i }))
.toBeVisible();
// Context A should NOT be automatically logged in
await pageA.reload();
await expect(pageA).toHaveURL(/\/login/);
await contextA.close();
await contextB.close();
});Assrt Equivalent
# scenarios/magic-link-cross-device.assrt
describe: Magic link works when opened in a different browser
given:
- Browser A is on the login page
- Browser B is a fresh browser with no session
steps:
- in Browser A, fill the email and click "Send magic link"
- retrieve the magic link from the email
- in Browser B, navigate to the magic link URL
expect:
- Browser B is redirected to /dashboard
- Browser B shows the dashboard heading
- Browser A is still on /login after reload8. Scenario: Bypassing Magic Link in CI with Direct Token Injection
The scenarios above are essential for verifying the magic link flow itself. But most of your test suite does not test auth; it tests features that require auth. For those tests, going through the full magic link flow on every test case is slow, fragile, and unnecessary. Instead, bypass the email flow entirely by generating a session token directly through your auth provider's admin API.
This is not a shortcut. It is the correct architecture. Your magic link flow tests (Sections 3 through 7) verify that the email and token mechanics work. Your feature tests use direct token injection to start from an authenticated state without repeating the email flow.
Supabase: Generate a Session via the Admin API
// fixtures/auth.ts
import { createClient } from '@supabase/supabase-js';
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function createAuthenticatedSession(email: string) {
// Create or get the user
const { data: user } = await supabaseAdmin.auth.admin.createUser({
email,
email_confirm: true,
});
// Generate a magic link token via the admin API (no email sent)
const { data } = await supabaseAdmin.auth.admin.generateLink({
type: 'magiclink',
email,
});
return {
userId: user.user!.id,
accessToken: data.properties!.access_token,
refreshToken: data.properties!.refresh_token,
};
}Using the Session in Playwright
import { test as base, expect } from '@playwright/test';
import { createAuthenticatedSession } from './fixtures/auth';
// Extend the base test with an authenticated page
const test = base.extend<{ authedPage: any }>({
authedPage: async ({ page }, use) => {
const email = `ci+${Date.now()}@example.com`;
const session = await createAuthenticatedSession(email);
// Inject the session tokens into the browser
await page.goto('/');
await page.evaluate((tokens) => {
localStorage.setItem('supabase.auth.token', JSON.stringify({
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
}));
}, session);
// Reload to pick up the injected session
await page.reload();
await use(page);
},
});
test('authenticated feature test: skip magic link flow', async ({ authedPage }) => {
await authedPage.goto('/dashboard/settings');
await expect(authedPage.getByRole('heading', { name: /settings/i }))
.toBeVisible();
});Assrt Equivalent
# scenarios/feature-with-auth-bypass.assrt
describe: Feature test with direct auth bypass
given:
- I am authenticated via direct token injection
- I am on /dashboard/settings
steps:
- verify the settings heading is visible
- change the display name to "Test User"
- click "Save"
expect:
- a success toast appears
- the display name shows "Test User"9. Common Pitfalls That Break Magic Link Test Suites
Email Delivery Delay
The most common failure mode is a test that checks for the email too quickly and concludes it was never sent. In local development with Inbucket, emails typically arrive in under a second. In staging with a real SMTP provider, delivery can take 5 to 10 seconds. In CI with shared infrastructure, it can take even longer. Always use a polling loop with a generous timeout (at least 30 seconds) rather than a single check.
Incorrect Link Extraction Regex
Email HTML is not the same as browser HTML. Email clients and renderers often encode ampersands as &, wrap long URLs across lines with soft line breaks, and add tracking parameters. A regex that works on the raw URL will fail on the rendered email body. Always decode HTML entities after extraction, and test your regex against the actual email HTML from your provider, not a hand-crafted string.
URL Encoding Issues
Magic link URLs often contain base64-encoded tokens with characters like +, /, and = that get URL-encoded in transit. If your extraction preserves the encoded form but your server expects the decoded form (or vice versa), the token validation will fail. Always pass the extracted URL through new URL() and use searchParams.get() to retrieve the token value, which handles decoding automatically.
Token Expiry Race Conditions
If your CI pipeline is slow, the token may expire between the moment the email is received and the moment your test navigates to the link. This manifests as intermittent failures that only appear in CI. The fix is twofold: use a longer TTL in your test environment (30 minutes instead of 5), and minimize the number of steps between email retrieval and link navigation.
Shared Inbox Pollution
If multiple test runs use the same email address, the inbox accumulates old magic link emails. When your test fetches the "latest" email, it may grab a message from a previous run with an already-expired or already-used token. Always use a unique email address per test run (for example, by appending Date.now() or a UUID to the local part). If your email provider supports it, clear the inbox before each test.
Not Testing the "Request New Link" Recovery Flow
When a magic link fails (expired, reused, or malformed), your app should offer the user a way to request a new one without starting over. Many teams test the error state but forget to verify the recovery path. Add an assertion that the retry button is visible and functional after every negative scenario.
10. Writing These Scenarios in Plain English with Assrt
Every scenario above involves at least three integration surfaces: the browser, an email API, and a token verification backend. Multiply that complexity by the six scenarios you actually need and you have a 400-line test file that breaks the first time your auth provider changes an email template or a token format. Assrt lets you describe each scenario in plain English, generates the Playwright TypeScript code including the email interception logic, and regenerates the selectors and API calls automatically when the underlying system changes.
Here is the full suite expressed as Assrt scenarios:
Assrt compiles each scenario block into the same Playwright TypeScript you saw in the sections above, committed to your repo as a real test you can read, run, and modify. When your auth provider updates an email template or changes the token format, Assrt detects the failure, analyzes the new email structure, and opens a pull request with the updated extraction logic. Your scenario file stays untouched.
Start with the happy path scenario. Once it is green in your CI, add the expired link test, then single-use enforcement, then cross-device, then the CI bypass fixture. In a single afternoon you can have the complete magic link coverage that most teams never manage to ship by hand.
Related Guides
How to Test Auth0 Universal Login
A practical, scenario-by-scenario guide to testing Auth0 Universal Login with Playwright....
How to Test Azure AD Login
A practical, scenario-by-scenario guide to testing Azure AD (Entra ID) login with...
How to Test Clerk Sign-In
A practical, scenario-by-scenario guide to testing Clerk authentication with Playwright....
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.