How to test a magic-link signup flow end to end
A magic-link signup has five hops and fails silently at each one. Here is where it breaks, how to test it by hand, in CI, and on production after every deploy.
Magic links are the friendliest way to sign someone up. No password to invent, no reset flow to build, one email and they are in. They are also the signup method most likely to break without anyone noticing, because the part that fails is the part you cannot see from your own server: the email.
This is a practical guide to testing that flow properly. Where it breaks, how to check it by hand in ten minutes, how to cover it in a test suite, and how to keep it covered on production after every deploy, which is the moment it usually goes wrong.
A magic-link signup is five hops, not one form
From the user's side there is a form and a click. From the system's side there are five hand-offs, and each one is a place where the flow can stop without an error reaching your logs.
- The form posts an email address to your server.
- Your server creates a token, stores it, builds a URL and asks an email provider to send it.
- The email provider accepts the message and tries to deliver it to the recipient's mail server.
- The inbox accepts it, filters it, and shows it to a person or a mail client.
- The link is opened, your server validates the token, sets a session cookie and redirects into the app.
Your monitoring, your tests and your logs almost always live at hop two. That is why a magic-link outage looks like nothing from the inside. The API call to the provider returned 200. The queue is empty. Everything you can see is fine, and nobody has been able to sign up since the deploy.
The seven ways it breaks
These are the failures that come up again and again, tied to the hop where they happen. If you only read one section, read this one.
- The form posts and nothing is sent. A background job that stopped consuming, an email API key that was rotated on the provider but not in production, a feature flag. The request succeeds because sending is deferred.
- Sent but never delivered. The provider accepted the message and the recipient's server refused it: a missing or broken SPF, DKIM or DMARC record after a DNS change, a sending domain that landed on a blocklist, a rate limit on a new sending address, or a hard bounce that the provider quietly suppressed.
- Delivered late. Provider queues under load, greylisting on the receiving side, or a warm-up throttle. The mail arrives in eight minutes. The user gave up after two and tried again, which created a second token and invalidated the first.
- The link points at the wrong host. The URL in the email is built from a base-URL variable. On a preview deployment or a freshly promoted environment that variable points at localhost, a preview hostname, or a domain without the
www. The mail arrives; the link 404s or opens the wrong app. - The token is used up before the click. Corporate mail filters such as link scanners open every URL in a message to check it for malware. If your token is single-use, the scanner consumed it, and the real person gets “this link has expired”. Mobile mail apps that prefetch links do the same.
- The link opens but no session is set. The cookie was written with
Secureon an http preview, with aDomainthat does not match, or with aSameSitevalue the redirect chain does not satisfy. The user lands on the app signed out and assumes they did it wrong. - The success page shows, the user is not signed in. The redirect fired before the cookie was committed, or the page rendered from a cache. The screen says welcome; the next click bounces to the login form.
Notice that only the first failure can be caught from your own server. The rest need a real inbox, a real click, and a look at what the browser ended up holding.
Testing it by hand in ten minutes
Before automating anything, do the honest version once. It takes ten minutes and it finds most of the list above.
- Use a real inbox on a domain you control. Not your own account, which already exists. Plus-addressing (
you+test1@yourdomain.com) gives you a fresh address for every attempt that still lands in one mailbox. Do one attempt from a Gmail address and one from an Outlook address too; their filters differ. - Start from a private window so no existing cookie rescues a broken flow.
- Time the arrival. Note when you pressed submit and when the mail appeared. Under twenty seconds is normal. Over a minute is a warning; over two is a problem your users will feel.
- Read the headers. Open the message source and find
Authentication-Results. You wantspf=pass,dkim=passanddmarc=pass. Anything else means the next provider may drop you. - Click the link from the mail client, not from a copied URL. Copying skips the tracking redirect some providers wrap links in, and that redirect is part of what can break.
- Check the cookie, not the page. In the browser devtools, confirm the session cookie exists, with the domain and flags you expect. Then reload the page. If you are still signed in, the flow works. If you are not, you have found failure six or seven.
- Open the link a second time. It should fail politely. If it signs you in again, the token is reusable, which is a security issue rather than a reliability one, but worth knowing.
Testing it in CI without a real inbox
In a test suite you usually do not want real email. The trick is to capture the link where your server hands it to the provider, then drive the browser through it. Two approaches work well.
Intercept the send. In the test environment, swap the email transport for one that writes the message to a place the test can read: an in-memory array, a table, a file. The test submits the form, reads the captured message, pulls the URL out of it with a regular expression, and opens it. This covers hops one, two and five, and it is fast.
Use the provider's sandbox. Most transactional providers have a test mode or sandbox addresses that accept mail without delivering it, and an API to read what was sent. That covers hop three as well, at the cost of a network call per test.
import { test, expect } from "@playwright/test";
import { lastEmailTo } from "./helpers/mail"; // reads the captured send
test("magic-link signup ends in a session", async ({ page, context }) => {
const email = `run-${Date.now()}@test.example.com`;
await page.goto("/signup");
await page.getByLabel("Email").fill(email);
await page.getByRole("button", { name: /continue|sign up/i }).click();
await expect(page.getByText(/check your email/i)).toBeVisible();
const message = await lastEmailTo(email, { timeoutMs: 10_000 });
const link = message.text.match(/https?:\/\/\S+/)?.[0];
expect(link, "no link in the welcome email").toBeTruthy();
expect(new URL(link!).host).toBe(new URL(page.url()).host); // failure 4
await page.goto(link!);
const session = (await context.cookies()).find((c) =>
/session/i.test(c.name)
);
expect(session, "link opened but no session cookie").toBeTruthy(); // failure 6
await page.reload();
await expect(page.getByRole("link", { name: /sign out/i })).toBeVisible(); // failure 7
const again = await page.goto(link!);
expect(again?.status(), "token should be single-use").toBeGreaterThanOrEqual(400);
});Two things this test cannot tell you: whether real inboxes accept your mail, and whether production has the same environment variables as the test run. Those are exactly the failures that hit after a deploy, which brings us to the last part.
Testing it on production, after every deploy
Everything above proves the code. Only a real signup on the real domain, with a real inbox at the other end, proves the deployment. The pieces you need:
- An inbox you can read by machine. A domain with a catch-all address and an inbound webhook or IMAP access, so a script can wait for a message to a fresh address.
- A fresh account per run, so the signup path runs rather than the login path, and a way to delete it afterwards.
- A browser that follows the link from the message, on your production host, and then checks the session the same way the test above does.
- A trigger on deploy, because a check that runs hourly finds a broken deploy up to an hour late.
- A rule for what counts as broken. No mail inside the window is broken. A link that 4xxs is broken. A button the script could not find is a question for a human, not a page at 3 a.m.
You can assemble that from Playwright, a mailbox and a cron job. It is also the email step of a Mystra run: every run signs up with its own address at inbox.mystra.run, waits for the welcome or verification mail inside the window you set, follows the first confirm or sign-in link that points at your host, and records the arrival time and the response of the link as checks. If the mail does not arrive in the window, the run is broken and the alert says so, with the screenshot of the “check your email” page as proof. The full step-by-step is in How a run works.
Checklist
- Once by hand: real inbox, private window, timed, headers read.
- Link clicked from the mail client; cookie checked; page reloaded; link opened twice.
- In CI: capture the send, assert the host, the cookie, the reload and the single use.
- On production: a fresh address that receives real mail, after every deploy, with a window you would defend and alerts only when it is proven broken.
See this run on your own app
Mystra walks signup, welcome email, checkout and paid access after every deploy, with proof. One app and 25 runs a month are free, no card.
Keep reading
Explainer8 min read
Synthetic monitoring vs uptime checks: what each one actually proves
An uptime check proves your homepage answers. Synthetic monitoring proves a browser could finish a task. Neither proves a customer could sign up and pay, unless you point it at that path.
Read the post →