How to Test Password Reset Emails
By Denis · Published 2026-09-16.
A broken signup frustrates a user; a broken password reset either locks them out for good or (much worse) lets someone else in. It deserves a real automated test, and the mailbox part is easier than you think.
The flow, split into testable halves
Password reset has an email half and a token half, and teams often test neither. The full cycle: request a reset for address A → a mail with a tokenized link arrives at A (and only A) → following the link yields a set-new-password form → the new password logs in → the old one does not → the link cannot be reused. Every one of those clauses is an assertion.
Setting up the mailbox
Use a disposable Tempx inbox as address A: any name at tempx.uk works with zero setup, mail arrives via our MX, and the inbox API returns the message with a 140-character text preview. Reset mails often lead with the link, so the preview is enough to extract it:
ADDR="reset-91b7c@tempx.uk"
await requestReset(ADDR);
const msg = await waitForMessage(ADDR, {
match: (m) => /reset your password/i.test(m.subject),
});
expect(msg.from).toContain("noreply@example.com");
const link = (msg.preview.match(/https:\/\/example\.com\/reset\?t=[A-Za-z0-9_-]+/) || [])[0];
expect(link, "reset link in mail").toBeTruthy();
Assertions that protect users
- The link goes to the requested address only. If you also control the app, confirm a reset request for a nonexistent address sends nothing observable, and never leaks whether an account exists.
- Single-use. Complete the reset, then follow the same link again: it must fail with a clear message, not open the form.
- Expiry. With a short-lived token, fake the clock (or lower the TTL in staging) and confirm an expired link is rejected politely.
- The switch. New password logs in; old password is rejected. This is the assertion users bet their data on.
- The wording. Reset mails that say "someone tried to access your account" are a security communication, assert the sentence survives template changes.
When the link hides in the HTML part
Some templates put the button link only in the HTML part, outside the 140-character text preview. Three ways in, cheapest first: assert on the subject plus the preview text and keep the deep check manual; read the full body with the MCP get_email tool (any MCP client, see the integration); or open the message in the Tempx app during a manual exploratory pass.
Testing the sending side too
If your staging should never be able to reset a real person's password, point its mailer at the capture-only SMTP gateway: every reset mail is stored in your inbox with the original recipient preserved in the headers, and nothing is ever delivered outside. Misconfigured staging becomes structurally incapable of emailing a real user.
Enumeration: what the reset endpoint reveals
Reset flows leak account existence more often than login flows, because teams polish the login message and forget the reset one. The test is mechanical: request a reset for a registered address and for a well-formed but unregistered one, then compare everything observable, the HTTP status, the response body, and critically the response time. A "user not found" branch that returns in 3 ms while the real branch spends 90 ms generating a token and queueing mail is a side-channel that scripts can measure at scale.
The same mail-side check matters: the unregistered request must send nothing, or at most a generic "if an account exists, instructions were sent" mail to that address, never to anyone else, and never with wording that confirms or denies existence. With a disposable inbox as the unregistered address, asserting "no mail arrived within the window" is a two-line wait-for-nothing check, and it catches the regression where someone "improves" the error message into a disclosure.
Reset storms: multiple requests, one valid token
Real users click "send reset link" four times, and attackers click it four hundred. Both stress the same policy question: when a second request arrives, what happens to the first token? The safe answers are "all previous tokens are invalidated" or "multiple tokens remain valid but each is single-use", the dangerous non-answer is "tokens accumulate and never expire," which multiplies the attack surface of a stolen inbox. Test it directly: request twice, complete the flow with the first link, and assert the outcome matches your documented policy.
Rate limiting deserves its own assertion: N requests in a minute should throttle, the throttle must not lock the account (only the channel), and (the clause teams forget) a reset the user did not request should notify the account's real channels when they exist. A "someone requested a password reset for your account" mail at the old address is the user's only alarm bell; make sure your staging flow proves it still rings.
The token half: expiry and clock control
Token TTL is the assertion most sensitive to test-harness time. Two workable approaches: in staging, configure the TTL to seconds, so the test can request, sleep past the window, and verify polite rejection; or make the issuance timestamp part of the token payload and have the test forge an old one through a staging-only endpoint. Sleeping for the production TTL in CI does not work, a fifteen-minute nap in a test suite is a cancelled suite.
Pair the expiry test with a format check: tokens should be unguessable (long, random, from a CSPRNG) and constant-length, and the reject path must not echo the token back or log it in full. These are quick greps over a handful of staged requests, and they catch the "we base64'd the user ID" regression that turns a reset link into an account enumeration oracle.
Staging fixtures that make reset tests cheap
The friction that keeps reset tests unbuilt is fixture cost, and three staging settings remove almost all of it. A short token TTL (seconds, not minutes) makes expiry tests instant instead of nap-length. A seeded user (or a signup step at spec start with a disposable inbox) gives you a known-good "existing account" without database surgery. And a mail endpoint that needs no credentials in CI (the by-name inbox read qualifies) removes the secret-management chore that quietly kills many an intended suite. Together, a full reset spec runs in about the time its flow takes by hand, which removes the last excuse.
The printable checklist version, for the wall: request → mail to the right address only → single-use → expiry rejected politely → old password dead, new one alive → rate-limit throttles the channel, not the account → unrequested resets alarm the real owner. Seven clauses, each one a line of test, all of them cheap once the fixtures exist.
Two production stories worth retelling
Story one: a team shipped a reset flow where the link worked perfectly, and the "success" page said "check your email" to a user who had already clicked it from the mail. Support volume spiked for a wording bug that no unit test caught and a two-line E2E spec would have. Story two: a staging config pointed reset mail at a sandbox everyone shared; a flaky run consumed another test's token, and half a day vanished before anyone thought to blame the fixture. Both stories end the same way: the assertions in this article, written down, would have caught the bug in minutes.
The lessons generalize, test the words as well as the mechanics (users act on what pages say), and never share a mailbox between tests (the fixture is the feature). Neither lesson is clever; that is why they belong in a suite rather than in institutional memory.
Create a temporary email address
Free, instant, no sign-up, messages auto-delete.
Go to your inbox