Testing Supabase Auth emails: Mailpit locally, SMTP on staging

Updated October 17, 2026 · Zyntra

Locally, the Supabase CLI captures every Auth email in Mailpit, so tests can read them there. On a hosted project the built-in email service only delivers to members of your project's team, two emails an hour, so a test that signs up a fresh address needs custom SMTP before any email reaches it.

Local: Mailpit at port 54324

supabase start runs Mailpit next to the database, at localhost:54324, and Supabase Auth sends every sign-up, magic-link and reset email there. That is enough to test your UI and redirects locally. It does not cover your hosted project's templates, SMTP settings or redirect allow-list; the Mailpit post goes through where a local catcher stops.

Hosted: the built-in service won't reach a test inbox

Supabase's docs list three limits of the default email service:

  • It refuses to deliver to addresses that are not part of the project's team.
  • It sends 2 emails per hour, and that number can change without notice.
  • It is best-effort, with no delivery or uptime guarantee.

So an E2E test that signs up run-123@… on a staging project gets no email at all. Set up custom SMTP for staging (the same provider as production, ideally), and the email rate limit becomes a setting you control.

Rate limits that fail tests

  • 60 seconds per user. By default a user can request a magic link or OTP once every 60 seconds, and the same window applies to sign-up confirmation and password reset. A test that requests a second link right after the first gets an error, not an email. Use a fresh address per test.
  • Per IP address. The sign-up, recover, resend, magic-link and OTP endpoints are limited to 30 requests per 5 minutes per IP. Parallel CI workers share one IP, so a large suite can hit it.
  • Link lifetime. Magic links and OTPs expire after one hour by default. Not a problem for a test that reads the email at once; a problem for a fixture that reuses yesterday's link.

The test: magic-link sign-in on staging

With the default template, {{ .ConfirmationURL }} is a link that goes through Supabase Auth and then redirects to your redirectTo, which has to match the project's Redirect URLs list. Staging projects often have a Site URL or redirect list copied from production, and only a real email shows where the link actually lands.

zyntra.ts
type Email = {
  subject: string;
  body: string;
  html: string | null;
  links: { url: string; text: string }[];
};

// Waits up to 30 s on the server for the first email to the address.
export async function lastEmail(address: string): Promise<Email> {
  const res = await fetch(
    `https://api.zyntra.app/emails/last?email=${encodeURIComponent(address)}`,
    {
      headers: { "X-API-Key": process.env.ZYNTRA_API_KEY! },
      signal: AbortSignal.timeout(45_000),
    },
  );
  if (!res.ok) throw new Error(`No email for ${address}: ${res.status}`);
  return res.json();
}

export const testAddress = (tag: string) =>
  `${process.env.ZYNTRA_TEAM_ID}.${tag}-${Date.now()}@zyntramail.com`;
magic-link.spec.ts
import { test, expect } from "@playwright/test";
import { lastEmail, testAddress } from "./zyntra";

test("magic link signs the user in", async ({ page }) => {
  test.setTimeout(90_000);
  const address = testAddress("supabase-magic");

  await page.goto("/login");
  await page.getByLabel("Email").fill(address);
  await page.getByRole("button", { name: "Send magic link" }).click();

  const email = await lastEmail(address);
  const supabaseHost = new URL(process.env.SUPABASE_URL!).host;
  const link = email.links.filter((l) => new URL(l.url).host === supabaseHost);
  expect(link).toHaveLength(1);

  await page.goto(link[0]!.url);
  // Lands on redirectTo, signed in.
  await expect(page).toHaveURL(/\/dashboard/);
});

Email change sends two emails

Supabase Auth has "secure email change" on by default (secure_email_change_enabled defaults to true in the Auth server): updateUser({ email }) sends a confirmation link to both the current and the new address. A test of that flow needs two inboxes; see the email-change post.

Questions

Why does the link say the token has expired when a user clicks it?
Supabase's docs name link scanners such as Microsoft Defender Safe Links: they open links in incoming email and use up the one-time token. The suggested fixes are an OTP ({{ .Token }} with verifyOtp) or a page that asks the user to press a button before confirming.
Can I add a test inbox to my Supabase team instead of using SMTP?
Team membership is for people. It also leaves you at 2 emails per hour, which a test suite uses up in its first run. Custom SMTP is what Supabase recommends for anything beyond trying templates out.