How to test magic links in CI

Magic links are the primary auth method for PLG SaaS - and universally untested in CI. Here’s how to fix that.

Why this is hard to test

  • E2E testing tools drive browsers but can’t receive emails
  • Email testing tools (Mailtrap, Mailosaur) receive emails but can’t drive browsers
  • Integrating them requires SMTP capture setup, email parsing, link extraction, and browser navigation - 3–4 separate tools
  • Most teams skip it entirely - magic link flows are the most critical PLG journey and the least tested

Approach 1: Playwright + Mailosaur

  1. 1.Create a Mailosaur account and set up a test email server
  2. 2.Configure your app to route emails to Mailosaur’s SMTP endpoint in test environments
  3. 3.In your Playwright test, trigger the magic link flow (e.g., enter email, click “Send magic link”)
  4. 4.Poll the Mailosaur API for the incoming email (with timeout and retry)
  5. 5.Parse the email HTML to extract the magic link URL
  6. 6.Navigate to the extracted URL in the browser
  7. 7.Assert that the authenticated session is established

Approach 2: Zerocheck with explicit login setup

  1. 1.Provide a safe login path using environment variables, a test account, or your own inbox tooling
  2. 2.Approve browser checks for the authenticated product behavior that should run on PRs
  3. 3.Keep inbox polling and magic-link extraction in customer-authored setup until first-class email primitives ship
  4. 4.Review screenshots, recordings, and step traces when auth-adjacent behavior regresses

Setting up email capture in CI

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: 500

Extracting the magic link URL

Once 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:test@example.com 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();
});

Testing link expiry and reuse

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();
});

OTP and passwordless variants

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("test@example.com");
  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();
});

Common pitfalls

  • Don’t skip email flow testing because it’s hard - it’s the most critical PLG user journey
  • Test expiry behavior explicitly - off-by-one errors in expiry time are common and devastating
  • Ensure your test environment routes emails to a capture service, not to real inboxes
  • Test the full flow: request → email delivery → link click → session - not just the link endpoint

FAQ

How do I intercept emails in CI?

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.

Can I test magic links without a third-party service?

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.

What’s the most common magic link bug?

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.

How to test magic links in CI

Skip the setup. Zerocheck handles it in plain English.

Get a demo