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.
Why reset and magic-link flows aren't just signup OTP
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.
Magic-link login: the shorter variant
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
- Matching link text instead of the href. The visible label is "Reset password," not a URL. Read the anchor's
hrefattribute, and scope it to your reset path so an unsubscribe or "view in browser" link never matches first. - Following the link in a fresh context. If the app sets a reset session cookie when the page loads, opening the URL in a new browser context or asserting the token straight against the backend skips that handoff. Navigate on the same
pageso the flow matches a real click from the mail client. - Never asserting the link is single-use. The happy path, click and reset, tells you nothing about the security property that makes the token safe. Consume the link, then follow it again and assert rejection.
- Reusing one inbox across tests. Reset and magic-link tokens linger in an inbox, so a shared address lets one test pick up another's link. Mint a fresh inbox per test, the same isolation rule as signup verification.
- Treating a magic link like a form. After opening it, assert the redirect target and a real session cookie, not just that some dashboard text is on screen.
- Ignoring enumeration. A reset form that says "no such account" is a data leak. Assert the same confirmation for registered and unregistered addresses, and assert no mail reaches the unregistered one.
- Unbounded or over-long waits. The absence-of-mail test should give up in a few seconds; a 30-second timeout there just slows the suite for a result you expect to be negative.
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.