Password reset flow testing: the edge cases that become account takeovers

Password reset is the auth flow attackers reach for first. Here's how to test it end to end — token reuse, expiry, enumeration, session invalidation — in five plain-English prompts, no Playwright.

playbookauthenticationpassword-resetno-code
monito

Password reset flow testing: the edge cases that become account takeovers

playbookauthenticationpassword-resetno-code
July 6, 2026

The "forgot password" link is the part of your auth system an attacker reaches for first. It's the one flow that, by design, hands an unauthenticated stranger a way to change a password — so every shortcut in it is a potential account takeover, not just a bug. And it's the flow nobody tests properly, because the happy path is trivial: request a link, click it, set a new password, done. The happy path is maybe 20% of what can go wrong.

This is the testing playbook we'd actually run against a password reset flow, written as five plain-English prompts. No Playwright, no token-decoding logic baked into a test suite, no selectors to maintain. If you've shipped a reset flow before and just want the prompts, scroll down. If you're about to ship one, the next two sections will save you a security advisory.

What "testing password reset" actually means

OWASP's Forgot Password Cheat Sheet is the reference here, and it's refreshingly concrete about what a secure reset flow has to do. Strip it down to the things you can actually observe from a browser and you get a checklist most teams have never fully run:

  1. Single-use tokens. A reset token works exactly once. After it's been used to set a new password, it's dead — even within its lifetime. OWASP lists "invalidated after they have been used" as a hard requirement, and it's the single most commonly violated one.
  2. Token expiry. A token presented after its lifetime fails cleanly. The cheat sheet calls for tokens that "expire after an appropriate period" — short enough that a leaked email from last week is worthless.
  3. No account enumeration. The page after "send me a reset link" returns the same message and in the same amount of time whether or not an account exists. OWASP is explicit: "return a consistent message for both existent and non-existent accounts" and "ensure that the time taken for the user response message is uniform." Miss either and you've built a free user-existence oracle.
  4. Rate limiting on requests. Submitting the same address a hundred times doesn't fire a hundred emails. OWASP frames this as protecting the user's inbox from being flooded, not just protecting your mail budget.
  5. Session handling after reset. Resetting a password invalidates other sessions (or asks the user whether to), and — per OWASP — does not automatically log the user in. The old password stops working immediately.
  6. No account lockout via reset. Requesting a reset for someone else's account must not lock them out. OWASP calls this out directly: "accounts should not be locked out in response to a forgotten password attack," because that turns the reset form into a denial-of-service tool against any known username.

Anything less than this is a smoke test that proves the email sends. We're going to write prompts for all of it.

The bug everyone ships at least once

Before the prompts, the failure mode to hunt first: a reset token that stays valid after it's been used.

It happens because the naive implementation treats "the password was changed" and "the token was consumed" as two separate facts, and only reliably writes the first one. The reset handler verifies the token, updates the password, redirects to a success page — and the line that marks the token consumed either runs in a code path that the redirect skips, or was never written because "the password already changed, who'd reuse it?"

Plenty of people would. If the token survives in an inbox, a forwarded email, a browser history, or a proxy log, a single-use failure means anyone who finds it later can change the password again and walk in. OWASP's wording is blunt for a reason: tokens must be "single use and expire after an appropriate period." Those are two independent checks, and the single-use one is the one teams forget.

You can't see the database from a browser, but you can see the visible consequence — use a token to set a password, then try the same token again. If the second attempt is accepted, the enforcement is missing. Prompt 2 below does exactly that.

Setup: what the agent needs

You want a staging environment where:

  • Reset emails land in a real test inbox you can read — a @yourapp.test catch-all, or a hosted service like Mailosaur or Mailtrap with a readable message URL.
  • The token TTL matches production. Don't test against a 24-hour TTL "to make it easier."
  • Rate limiting is enabled with production-like thresholds.

The Agent executor reads the reset email the way a person would — give it the inbox URL or the mailbox's API endpoint. Don't bake inbox credentials into the prompt text; pass them through your Test Scenario config and reference them as a placeholder like the inbox URL token below. Throughout, the target is https://staging.yourapp.com — substitute your own.

Prompt 1: the happy path

The boring one. If this is broken, every other test is noise.

Go to https://staging.yourapp.com/forgot-password.
Enter the email "reset-happy@yourapp.test" and request a password reset.

Verify the page shows a generic "check your email" confirmation after
submitting (not "we found your account" or anything account-specific).

Then open the test inbox at INBOX_URL, find the most recent reset email
for that address, and click the reset link inside it.

On the reset page, set a new password "NewPass123!" and submit.

Verify that:
- The reset succeeds with a clear confirmation
- You are NOT automatically logged in (OWASP recommends sending the user
  to the normal login screen, not straight into the app)
- Logging in with "NewPass123!" then works on the normal login page

If any of those fail, that's the bug.

The bug class this catches that you wouldn't think to script: a reset link that's double-percent-encoded and 404s, a confirmation page that leaks account existence in its wording, an auto-login that quietly skips the login-screen step OWASP warns against.

Prompt 2: token reuse

The test most teams skip, and the one most likely to surface a real vulnerability.

Go to https://staging.yourapp.com/forgot-password.
Request a password reset for "reset-reuse@yourapp.test".

Open the test inbox at INBOX_URL and locate the reset link URL.
Copy the URL.

In a fresh incognito window, visit the URL, set the password to
"ResetOnce123!", and confirm the reset succeeds.

Now, in another fresh incognito window, visit the SAME reset URL again.

Verify that the second visit fails with a clear message (something like
"this link has already been used or expired").
Verify that the second visit does NOT let you set another password.

The failure you're hunting: the token is single-use in intent but not in enforcement, so the same link sets a password twice. The agent won't reproduce a millisecond-level double-submit race, but it nails the structurally identical "I used this link yesterday and it still works" case — which is the one that shows up in a bug bounty report.

Prompt 3: expired token

The TTL test. You need a way to age a token past its lifetime — an admin endpoint, a non-prod query parameter that shortens the TTL, or a clock you can advance. Most staging setups have one; if yours doesn't, the cheapest version is a request-side override the endpoint only honors outside production.

Go to https://staging.yourapp.com/forgot-password?ttl_override=5s and
request a reset for "reset-expired@yourapp.test".

Open the test inbox at INBOX_URL and find the reset link URL.
Wait 30 seconds without clicking it.

Then visit the reset link URL.

Verify the page shows a clear "this link has expired" message.
Verify you are NOT able to set a new password.
Verify the page offers a useful next step — a button to request a fresh
link — and that the button actually works.

The bug to catch: an expired token that fails silently on a blank page, or — far worse — one that's accepted anyway because the expiry check reads the wrong column or compares timestamps in the wrong timezone.

Prompt 4: account enumeration

The security one. OWASP devotes specific language to it because it's so easy to ship: the request flow must be indistinguishable for real and fake accounts, in both wording and timing.

Go to https://staging.yourapp.com/forgot-password.

Step 1: Submit a reset request for an address you know is NOT registered:
"definitely-not-a-user-{random}@yourapp.test".
Record the exact confirmation text, the visible response time, and any
console or network detail you can see.

Step 2: Submit a reset request for an address that IS registered:
"reset-happy@yourapp.test".
Record the exact confirmation text, the response time, and the same details.

Compare the two. They should be word-for-word identical and arrive in a
similar amount of time. If the wording differs ("check your email" vs.
"no account found"), or one is noticeably slower than the other, that's an
account enumeration vulnerability — flag it as a bug with the evidence.

OWASP's guidance is two-pronged on purpose: "return a consistent message" and "ensure that responses return in a consistent amount of time." A team that fixes the wording and leaves a 200ms timing gap has fixed half the bug. The agent can't measure microseconds, but it reliably tells apart differences in the 100ms-and-up range — which is where the lazy "quick exit on unknown account" implementations live.

Prompt 5: session invalidation after reset

The one that matters most after a real compromise, and the one almost nobody tests. If an attacker had a session open, resetting the password must not leave that session alive.

Set up two browser sessions for the same test account.

Session A: log in to https://staging.yourapp.com as
"reset-sessions@yourapp.test" with its current password, and confirm
you're on the dashboard.

Session B (fresh incognito): go to https://staging.yourapp.com/forgot-password,
request a reset for "reset-sessions@yourapp.test", open the test inbox at
INBOX_URL, click the link, and set a new password "Rotated123!".

Now go back to Session A and try to do something that requires being
logged in — navigate to the dashboard or reload a protected page.

Verify Session A is no longer authenticated (it should be forced back to
login), OR that the app clearly told the user it was ending other sessions.
Then verify the OLD password no longer works on the login page, and the
NEW password does.

OWASP recommends the app either invalidate existing sessions automatically or ask the user whether to. The bug to catch is the silent third option: the password changes, but the attacker's already-open session keeps working because session invalidation was never wired to the reset event. That's the difference between "we forced a reset" and "we forced a reset and it actually evicted them."

What you do with a failure

A failed run on a reset flow gives you the same artifact as any other Test Run: a Monito Session with a screenshot timeline, the full network log (the POST /forgot-password request and the POST /reset-password call with payloads), console output, and the agent's reasoning at each branch. For auth bugs the network log is where you'll live — it's where you'll see the reset endpoint return success on a reused token, or the timing gap between a real and a fake account. If you're new to reading agent runs instead of green checkmarks, why AI QA agents find bugs your scripts miss is the longer argument for why the session beats the assertion.

These five prompts are close cousins of the ones in our magic-link auth playbook and the signup flow guide — token reuse, expiry, and enumeration recur across every credential flow, which is exactly why describing the intent once and reusing it beats re-scripting selectors per form.

Run these on every release

Five prompts, a few minutes of agent time per full sweep, well under a dollar in run credits (a typical full run is 8–13 credits, roughly $0.08–$0.13). Wire them into a CI/CD step that runs against every preview deploy or against staging on a schedule. The prompts don't rot when you rename the "Send reset link" button or restyle the form — there are no selectors to update — so the maintenance cost over the next year rounds to zero.

Password reset is the flow where a single missed check equals a stranger inside someone's account. Five prompts is cheap insurance against the one bug class that turns "forgot password" into "forgot whose account this is."


Want to run these now? Sign up for Monito, drop the five prompts into a new Project pointed at your staging URL, wire up a test inbox, and go. Your first run is free.

All Posts