TempMaily.co
Developers

Test Password Reset Flow and Magic Links End-to-End

TempMaily Team12 min read

A broken signup email is annoying; a broken password reset or magic-link email locks an existing user out of their own account. Yet most end-to-end suites test the signup one-time code and stop there, leaving the two flows where email is load-bearing completely uncovered. This post shows how to test a password reset flow and a magic-link login end-to-end in Playwright, including the two security assertions that separate a real test from a happy-path click-through.

Quick answer

To test a password reset flow, drive the real "forgot password" form with a disposable email address, read the reset email back from a mail API, extract the reset URL from the message body, and open that URL in the same browser context. Set a new password, assert the user can sign in with it, then follow the link a second time and assert it is now rejected. Magic links work the same way up front, but their success condition is an authenticated session rather than a saved password.

The three things that make these flows different from a signup code: you extract a URL, not a six-digit number; the token in that URL has expiry and one-time-use semantics you must assert; and a magic link changes the browser's auth state the instant it opens. Give every test its own inbox so parallel runs never cross links. The rest of this guide is the working code.

If you have already wired up signup verification in Playwright, you have the plumbing: a disposable inbox, a poll loop, a body parser. Reset and magic-link flows reuse all of it, but three differences change what you assert.

You extract a link, not a code. A signup email says "your code is 123456" and you type six digits into a field. A reset email says "click here to reset your password" and the thing you need is a full URL buried in an anchor tag, usually with a signed token in the query string. Regexing for digits gives way to parsing an href.

The token expires and burns on use. A signup code is often reusable within its window and forgiving. A reset or magic-link token is a bearer credential: whoever holds it can change a password or log in. So it is meant to be single-use and short-lived, and those two properties are exactly what an attacker probes. If your test never checks that a consumed link stops working, you are not testing the security-critical part of the flow.

A magic link creates a session by itself. Opening a reset link lands you on a form. Opening a magic link logs you straight in. That means the magic-link assertion is about session state, a redirect and a cookie, not about a form you fill in afterward.

Everything else, minting the inbox and polling for the message, is shared with the signup pattern, so this guide leans on that helper and focuses on what is new.

Extracting the reset URL from the email

The get-message endpoint returns both bodyText and sanitized bodyHtml. For a reset link, prefer the HTML part and read the anchor's href attribute. Reading the attribute rather than the visible text matters because a button labelled "Reset my password" hides the real URL, and text-part URLs are frequently wrapped across lines by the sender's mail composer.

The dependable approach is to parse the HTML and query the anchor. With a small parser like node-html-parser you select the reset link by its path and read the attribute directly:

// tests/helpers/reset-link.ts
import { parse } from "node-html-parser";

// Pull the reset (or magic-link) URL out of a message. Parse the HTML and read
// the anchor's href, scoped to your own path so a footer or unsubscribe link
// never matches first. Fall back to a scoped regex over the text part.
export function extractLink(
  message: { bodyText: string; bodyHtml: string },
  pathFragment: string, // e.g. "/reset-password" or "/auth/magic"
): string {
  if (message.bodyHtml) {
    const root = parse(message.bodyHtml);
    const anchor = root
      .querySelectorAll("a")
      .find((a) => a.getAttribute("href")?.includes(pathFragment));
    const href = anchor?.getAttribute("href");
    if (href) return href;
  }
  // Text-part fallback: match a full URL that contains the expected path.
  const re = new RegExp(`https?://\\S*${pathFragment}\\S*`, "i");
  const bare = message.bodyText?.match(re);
  if (bare) return bare[0];
  throw new Error(`No link containing "${pathFragment}" found in message`);
}

// Read a named token out of the link's query string via the URL parser, rather
// than a second regex. Returns null when the param is absent.
export function tokenFromLink(link: string, param = "token"): string | null {
  return new URL(link).searchParams.get(param);
}

A scoped regex over bodyText is perfectly fine when your reset email includes the raw URL as plain text and you anchor the pattern to your own path, as the fallback above does. Where regex gets you in trouble is matching an unanchored https?://\S+, which happily grabs a logo link or a "view in browser" URL sitting above the real button. Anchor to the path either way, whether you parse or match.

The worked example: password reset end-to-end

This spec reuses createInbox, waitForMessage, and deleteInbox from the signup verification helper and adds extractLink. It creates an inbox per test, runs the real reset, follows the link in the same browser context, sets a new password, and asserts a login works. Then it does the part that matters: it proves the link is one-time use.

// tests/password-reset.spec.ts
import { test, expect } from "@playwright/test";
import { createInbox, waitForMessage, deleteInbox } from "./helpers/mailbox";
import type { Inbox } from "./helpers/mailbox";
import { extractLink } from "./helpers/reset-link";

const NEW_PASSWORD = "Rotated-Pw-After-Reset-9!";

test.describe("password reset", () => {
  let inbox: Inbox;

  test.beforeEach(async () => {
    inbox = await createInbox();
    // Assumes an account already exists for inbox.address. Seed it via your
    // API/fixtures, or run a real signup in a prior step.
    await seedVerifiedAccount(inbox.address);
  });

  test.afterEach(async () => {
    if (inbox) await deleteInbox(inbox.id);
  });

  test("user can reset their password and the link burns on use", async ({ page }) => {
    // 1. Request the reset with the real form.
    await page.goto("/forgot-password");
    await page.getByLabel("Email").fill(inbox.address);
    await page.getByRole("button", { name: "Send reset link" }).click();
    await expect(page.getByText(/check your email/i)).toBeVisible();

    // 2. Read the reset email and pull the URL out of the anchor href.
    const message = await waitForMessage(
      inbox.id,
      (subject) => /reset|password/i.test(subject),
      { timeoutMs: 30_000, intervalMs: 2_000 },
    );
    const resetUrl = extractLink(message, "/reset-password");

    // 3. Follow the link in the SAME browser context so any reset session
    //    the app sets on the reset page is preserved through the form submit.
    await page.goto(resetUrl);
    await page.getByLabel("New password").fill(NEW_PASSWORD);
    await page.getByRole("button", { name: "Set new password" }).click();
    await expect(page.getByText(/password (has been )?updated/i)).toBeVisible();

    // 4. Assert the new credential actually works.
    await page.goto("/login");
    await page.getByLabel("Email").fill(inbox.address);
    await page.getByLabel("Password").fill(NEW_PASSWORD);
    await page.getByRole("button", { name: "Sign in" }).click();
    await expect(page).toHaveURL(/\/dashboard/);

    // 5. The security assertion: the consumed link must now be dead.
    await page.goto(resetUrl);
    await expect(page.getByText(/(expired|invalid|no longer valid)/i)).toBeVisible();
    await expect(page.getByLabel("New password")).toHaveCount(0);
  });
});

Step 3 is the subtle one. Some apps set a short-lived session cookie when the reset page loads, and the change-password request expects it. Following the link with page.goto(resetUrl) on the same page keeps that cookie in the browser context, so the flow behaves exactly as it does for a real user clicking from their mail client. Opening the URL in a fresh context, or worse, asserting the token straight against your backend, skips that handoff and tests something the user never experiences.

The token-invalidation assertion

Step 5 covers reuse after consumption. The companion check is that requesting a second reset invalidates the first link, which is how most one-time-token schemes behave: the newest token wins and older ones are revoked. It reads cleanly as its own test:

test("requesting a new reset invalidates the previous link", async ({ page }) => {
  // First reset request — capture its link but do NOT consume it.
  await requestReset(page, inbox.address);
  const first = extractLink(
    await waitForMessage(inbox.id, (s) => /reset|password/i.test(s)),
    "/reset-password",
  );

  // Second reset request supersedes the first.
  await requestReset(page, inbox.address);
  await waitForMessage(inbox.id, (s) => /reset|password/i.test(s), {
    // Wait for the SECOND message; the inbox now holds two.
    timeoutMs: 30_000,
  });

  // The first link must be rejected now that a newer one exists.
  await page.goto(first);
  await expect(page.getByText(/(expired|invalid|no longer valid)/i)).toBeVisible();
});

These two assertions, reuse-after-consumption and superseded-by-newer, are deterministic and belong in every suite. True clock-based expiry, where a link dies after fifteen minutes untouched, is harder to exercise end-to-end without waiting out the real TTL. The practical route is a backend flag that mints a token with a one-second lifetime in your test environment, then asserting the link is dead a moment later. Absent that hook, cover the two deterministic cases and document the TTL as a unit-tested concern on the server.

A magic link skips the password entirely: opening it logs the user in. So the assertions move from a form to the session. After following the link, check the redirect target and confirm an auth cookie now exists on the context.

test("magic link logs the user straight in", async ({ page, context }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByRole("button", { name: "Email me a login link" }).click();
  await expect(page.getByText(/check your email/i)).toBeVisible();

  const link = extractLink(
    await waitForMessage(inbox.id, (s) => /sign in|log in|login link/i.test(s)),
    "/auth/magic",
  );

  // Opening the link authenticates — assert the landing page, not a form.
  await page.goto(link);
  await expect(page).toHaveURL(/\/dashboard/);
  await expect(page.getByText(/welcome back/i)).toBeVisible();

  // Assert the session cookie is actually set on the browser context.
  const cookies = await context.cookies();
  expect(cookies.some((c) => c.name === "session" && c.value.length > 0)).toBe(true);

  // Same one-time-use rule applies — the link must not log a second visitor in.
  await context.clearCookies();
  await page.goto(link);
  await expect(page).not.toHaveURL(/\/dashboard/);
});

The redirect assertion catches a common real bug: the link authenticates but drops the intended destination, dumping the user on a generic home page instead of where they were headed. Asserting toHaveURL(/\/dashboard/) rather than just "am I logged in" is what surfaces that. The cookie check confirms the session is genuinely established on the client, not merely implied by the page content.

Testing "email not found" without leaking accounts

A reset form that responds "no account with that email" whenever an address is unknown is an account-enumeration oracle: an attacker submits a list and learns which addresses are registered. The correct behavior is to show the same confirmation for every submission and only actually send mail when a real account exists. That is directly testable with a disposable inbox, because you can assert the absence of mail.

test("reset form does not reveal whether an email is registered", async ({ page }) => {
  const unknown = await createInbox(); // never seeded — no account behind it
  try {
    await page.goto("/forgot-password");
    await page.getByLabel("Email").fill(unknown.address);
    await page.getByRole("button", { name: "Send reset link" }).click();

    // Same confirmation a real account gets — no "not found" tell.
    await expect(page.getByText(/check your email/i)).toBeVisible();

    // And no email should actually arrive at an unregistered address.
    await expect(
      waitForMessage(unknown.id, () => true, { timeoutMs: 8_000 }),
    ).rejects.toThrow(/No matching message/);
  } finally {
    await deleteInbox(unknown.id);
  }
});

Here the timeout is a feature, not a fallback. waitForMessage is expected to give up and throw, and the test asserts exactly that rejection. Keep this window short so the suite is not blocked waiting to confirm a non-event, but long enough that a genuinely slow send would still be caught by a real reset test elsewhere.

Common mistakes

Next steps

Password reset and magic-link flows are the two places where a broken email path locks real users out, so they deserve the coverage most suites give only to signup. The pattern is the same throughout: a disposable inbox per test, the real form, the URL pulled from the message body, the link followed in the same browser context, and the single-use rule asserted rather than assumed. On Cypress the shape carries over almost unchanged if you follow the Cypress email testing setup, and the framework-agnostic guide covers the poll-and-extract flow without a runner. When you wire these into a pipeline, the email testing in CI guide covers secrets, parallelism, and timeouts.

Programmatic inbox creation is part of TempMaily Premium, and the API reference is the source of truth for every endpoint, field, and limit used above. Mint an inbox per test, follow the link, and delete "we don't test reset in CI" from your test plan.

Frequently asked questions

How do I test a password reset flow end-to-end?

Drive the real 'forgot password' form with a disposable email address, then read the reset email back from a mail API instead of a human checking an inbox. Pull the reset URL out of the message body, open it in the same browser context so any reset session carries over, set a new password, and assert the user can sign in with it. The one part signup tests skip is the security half: after consuming the link once, follow it again and assert it is now rejected. All of that runs inside a normal end-to-end spec with no shared test account.

How do you extract a reset link from an email in a test?

Fetch the full message from the mail API and read its bodyHtml, which contains the reset button as an anchor tag. Parse the anchor's href attribute rather than matching the visible link text, because email clients often show a shortened or wrapped label while the real URL lives in the attribute. Scope the match to your own reset path so a footer or unsubscribe link never wins. Once you have the URL, pass it through the URL constructor to read the token query parameter cleanly.

How is testing a magic link different from a password reset?

A magic link authenticates the user the moment it is opened, so following it should land them logged in with no password step in between. That means your assertions target the session, not a form: check the redirect goes to the intended landing page and that an auth cookie is now set on the browser context. A password reset link, by contrast, only unlocks a change-password form and grants no session until the new password is saved. Both carry a single-use token, but the magic link's success condition is an authenticated session while the reset link's is a working new credential.

How do you test that a password reset token is one-time use?

Complete the reset once so the token is consumed, then navigate to the exact same reset URL a second time and assert the app now rejects it with an expired-or-invalid message. A second, related check is that requesting a fresh reset invalidates any earlier link: capture the first URL, trigger another reset, then confirm the first URL no longer works. Both assertions are fully deterministic and need no clock manipulation. True time-based expiry is harder to test end-to-end and usually needs a backend flag to mint a short-lived token in your test environment.

Should a password reset form reveal whether an email exists?

No. A reset form that says 'no account found' for unknown addresses hands attackers a way to enumerate which emails are registered. The form should return the same confirmation screen whether or not the address exists, and only send mail when there is a real account behind it. You can test this by submitting a never-registered disposable address, asserting the UI shows the usual 'check your email' message, and confirming no email ever arrives at that inbox.

Do I need a separate inbox for each password reset test?

Yes, mint a fresh disposable inbox at the start of every test. Reset and magic-link flows are stateful in a way signup is not: a token from one run can linger and be picked up by another, and a shared inbox makes parallel workers read each other's links. Creating a unique inbox per test means each reset targets an address no other test touches, which is what keeps parallel runs from going flaky. Tear the inbox down afterward so it does not count against your storage quota.

Get a free disposable inbox

A live throwaway address, no signup, real-time delivery. Upgrade to Premium for custom domains, forwarding, and no expiry.