Magic links are the primary auth method for PLG SaaS - and universally untested in CI. Here’s how to fix that.
There are two viable approaches for intercepting magic link emails in CI: hosted services and self-hosted containers. Each has real tradeoffs worth understanding before you commit. Hosted services like Mailosaur (~$90/month) and MailSlurp (free tier available, paid for volume) give you a managed SMTP endpoint and a REST API to query incoming mail. You point your app’s SMTP config at their server, trigger the magic link flow, then poll their API for the message. The upside is zero infrastructure to maintain. The downside is cost, and you’re adding a network dependency to your CI pipeline — if their API is slow or down, your tests flake. Self-hosted tools like Mailpit (the actively maintained successor to MailHog) run as a lightweight Docker container alongside your test app. Mailpit exposes an SMTP server on port 1025 and a REST API on port 8025. It’s free, runs entirely inside your CI environment, and adds no external dependency. The tradeoff: you own the container lifecycle, and you need to wire up the docker-compose networking so your app can reach it. For most teams, Mailpit in Docker is the right starting point. It eliminates the cost and external dependency, and the setup is straightforward. Reserve hosted services for cases where you need advanced features like email rendering previews or shared test inboxes across distributed teams.
# docker-compose.ci.yml
services:
app:
build: .
environment:
SMTP_HOST: mailpit
SMTP_PORT: 1025
SMTP_SECURE: "false" # No TLS for local capture
APP_URL: http://app:3000
ports:
- "3000:3000"
depends_on:
- mailpit
mailpit:
image: axllent/mailpit:latest
ports:
- "8025:8025" # Web UI + REST API
- "1025:1025" # SMTP server
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1 # Accept any credentials
MP_MAX_MESSAGES: 500Once your CI captures the email, you need to extract the magic link URL from the HTML body. Most magic link implementations embed a URL with a token parameter — something like https://app.example.com/auth/verify?token=abc123. The extraction pattern is the same regardless of which email capture service you use. The general approach: poll for the email (with a timeout), grab the HTML body, and parse out the URL. A regex works for most cases, but be aware of a common gotcha: if your app uses an email service like SendGrid or Postmark with click tracking enabled, the raw URL in the email HTML will be rewritten to a tracking redirect (e.g., https://url1234.sendgrid.net/ls/click?...). In your test environment, either disable click tracking or extract the final destination URL from the tracking wrapper. Another subtlety: some email templates use HTML entities or URL encoding in the href attribute. Always decode the extracted URL before navigating to it. The code example below shows a complete Playwright + Mailosaur flow that handles polling, extraction, and navigation. If you’re using Mailpit instead, swap the Mailosaur API call for a GET to http://localhost:8025/api/v1/search?query=to:[email protected] and parse the JSON response — the structure differs but the extraction logic is identical.
import { test, expect } from "@playwright/test";
import Mailosaur from "mailosaur";
const mailosaur = new Mailosaur(process.env.MAILOSAUR_API_KEY!);
const serverId = process.env.MAILOSAUR_SERVER_ID!;
test("magic link login completes successfully", async ({ page }) => {
const testEmail = `test.${Date.now()}@${serverId}.mailosaur.net`;
// 1. Trigger the magic link flow
await page.goto("/login");
await page.getByLabel("Email").fill(testEmail);
await page.getByRole("button", { name: "Send magic link" }).click();
await expect(page.getByText("Check your email")).toBeVisible();
// 2. Poll for the email (30s timeout)
const email = await mailosaur.messages.get(serverId, {
sentTo: testEmail,
}, { timeout: 30000 });
// 3. Extract the magic link from HTML body
const html = email.html!.body!;
const match = html.match(/href="(https?:\/\/[^"]*\/auth\/verify[^"]*)"/i);
expect(match).toBeTruthy();
const magicLinkUrl = match![1].replace(/&/g, "&");
// 4. Navigate to the magic link
await page.goto(magicLinkUrl);
// 5. Verify authenticated state
await expect(page.getByText("Dashboard")).toBeVisible();
await expect(page.getByRole("button", { name: "Log out" })).toBeVisible();
});A working magic link is only half the story. You also need to verify that links expire after the configured TTL and reject reuse after the first click. These are security-critical behaviors, and off-by-one bugs here are surprisingly common — especially when developers mix up seconds and milliseconds in expiry calculations. Test three distinct cases: (1) a fresh link works and establishes a session, (2) an expired link shows an appropriate error and does not authenticate, and (3) a link that has already been used once rejects the second attempt. For case 1, the standard flow from the previous section covers it. Cases 2 and 3 require a bit more setup. For expiry testing, the cleanest approach is to configure a short TTL in your test environment. Set MAGIC_LINK_EXPIRY_SECONDS=5 in your CI environment variables, then add a deliberate delay between requesting the link and clicking it. Avoid mocking system clocks in E2E tests — it’s fragile and doesn’t test what actually runs in production. A short real TTL is more reliable. For reuse testing, click the link once (assert success), then navigate to the same URL again in a new browser context. The second attempt should fail. Use a fresh browser context, not just a new tab, to ensure you’re not relying on session cookies from the first click. Watch for this bug: the link technically expires at timestamp X, but the server checks token_created_at + ttl > now using greater-than instead of greater-than-or-equal. This means a link with a 15-minute TTL expires at 14 minutes and 59.999 seconds. Always test at the boundary.
// Expiry test: use a short TTL in test env (MAGIC_LINK_EXPIRY_SECONDS=5)
test("expired magic link is rejected", async ({ page }) => {
const magicLinkUrl = await requestAndExtractMagicLink(page);
// Wait for the link to expire (TTL = 5s in test env)
await page.waitForTimeout(6000);
await page.goto(magicLinkUrl);
await expect(page.getByText(/expired|invalid/i)).toBeVisible();
// Verify no session was created
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
});
// Reuse test: same link should fail on second click
test("used magic link rejects second attempt", async ({ page, browser }) => {
const magicLinkUrl = await requestAndExtractMagicLink(page);
// First click: should work
await page.goto(magicLinkUrl);
await expect(page.getByText("Dashboard")).toBeVisible();
// Second click: new context, no session cookies
const freshContext = await browser.newContext();
const freshPage = await freshContext.newPage();
await freshPage.goto(magicLinkUrl);
await expect(freshPage.getByText(/already used|invalid/i)).toBeVisible();
await freshContext.close();
});The same testing patterns apply to other passwordless auth methods: TOTP (Google Authenticator, Authy), SMS OTP (Twilio, Vonage), and passkeys (WebAuthn). The core challenge is identical — your test needs to produce or intercept a credential that lives outside the browser. For TOTP, the trick is to capture the shared secret during test account setup rather than scanning a QR code. When your app displays the authenticator setup screen, it typically also shows the secret as a text string (or you can extract it from the otpauth:// URI in the QR code data). Store that secret, then use a library like otplib to generate the current 6-digit code at test time. This is deterministic and reliable — no need to mock anything. For SMS OTP, Twilio provides magic test credentials that work without sending real messages. The test credential SID and auth token, combined with specific magic phone numbers (like +15005550006), let you trigger verification flows that auto-complete. This means your CI never sends real SMS messages and never depends on carrier delivery. For passkeys and WebAuthn, Playwright has built-in support via the Virtual Authenticator API. You can create a virtual authenticator in your test, register a credential, and use it to authenticate — all without a physical security key or biometric sensor. Call cdpSession.send(‘WebAuthn.enable’) followed by cdpSession.send(‘WebAuthn.addVirtualAuthenticator’) to set it up. In all cases, the principle is the same: control the secret in your test environment so you can deterministically produce the credential. Never rely on external delivery (real email, real SMS) in CI.
import { authenticator } from "otplib";
import { test, expect } from "@playwright/test";
test("TOTP login with authenticator app", async ({ page }) => {
// 1. Set up a test account with TOTP enabled
// Capture the secret from the setup flow
await page.goto("/settings/security");
await page.getByRole("button", { name: "Enable 2FA" }).click();
// Extract the secret from the otpauth:// URI or text display
const secret = await page
.getByTestId("totp-secret")
.textContent();
// 2. Generate the current TOTP code
const code = authenticator.generate(secret!.trim());
// 3. Enter the code to complete setup
await page.getByLabel("Verification code").fill(code);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page.getByText("2FA enabled")).toBeVisible();
// 4. Log out and log back in with TOTP
await page.getByRole("button", { name: "Log out" }).click();
await page.goto("/login");
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Password").fill("testpassword");
await page.getByRole("button", { name: "Log in" }).click();
// 5. Enter a fresh TOTP code (regenerate in case of time drift)
const loginCode = authenticator.generate(secret!.trim());
await page.getByLabel("Authentication code").fill(loginCode);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page.getByText("Dashboard")).toBeVisible();
});Use an SMTP capture service like Mailosaur, Mailtrap, or Mailpit. Zerocheck V1 does not provide built-in email capture, so route outbound test email through tooling you control.
Not through a built-in Zerocheck V1 primitive. Use your own inbox tooling, Mailpit in CI, or bypass email with a safe test-login/session setup, then let Zerocheck run the approved browser-visible checks.
Expiry time errors. A developer changes expiry from 24h to 1h and an off-by-one error makes links expire in 1ms. Without E2E testing, this goes to production and locks out users.