How to Test Forgot Password: A Complete Guide
How to Test Forgot Password: A Complete Guide begins with recognizing that the password recovery flow is a critical security and usability gate. A broken or confusing reset process can lock legitimate
How to Test Forgot Password: A Complete Guide begins with recognizing that the password recovery flow is a critical security and usability gate. A broken or confusing reset process can lock legitimate users out, expose accounts to credential stuffing, or leak sensitive data through side‑channels. Because the flow touches user‑input validation, out‑of‑band communication, token handling, and final password change, it is a prime target for both functional bugs and security flaws. This guide walks you through a complete, platform‑agnostic test matrix, shows how to execute those tests manually and with automation, highlights production‑only gotchas, and provides a reusable checklist you can embed in your release gate.
Why Forgot Password Testing Matters
The forgot‑password pathway is often the only self‑service mechanism users have when they lose access. If it fails, support costs rise, churn increases, and brand trust erodes. From a security perspective, the flow is a gateway to account takeover: weak tokens, missing rate limits, or information disclosure in error messages can let an attacker enumerate valid emails, brute‑force reset codes, or hijack the reset link. Moreover, accessibility regulations (WCAG 2.1 AA) require that the flow be operable via keyboard, screen readers, and assistive tech. Testing the flow therefore covers three intertwined concerns: usability, reliability, and security.
Core Components of a Forgot Password Flow
Understanding the building blocks lets you map test cases to specific code paths and infrastructure pieces.
Entry Point
The user typically clicks a “Forgot password?” link on the login screen or enters an identifier (email, phone, username) on a dedicated recovery page. This step must validate the identifier format, check whether the account exists without revealing that information, and trigger the out‑of‑band message.
Email/SMS Delivery
After validation, the backend generates a one‑time token, stores it (often hashed) with an expiry timestamp, and dispatches a message via an email provider or SMS gateway. The message contains a link or code that the user must return to the app or web portal.
Token Validation
When the user follows the link or enters the code, the backend verifies that the token matches a stored record, has not expired, and belongs to the correct identifier. Successful validation grants access to the reset password form.
Reset Password Form
Here the user supplies a new password, usually with confirmation and strength‑checking logic. The backend then replaces the old credential, invalidates any existing sessions (optional), and notifies the user of success.
Post‑Reset Handling
Some systems log the reset event, send a confirmation email, or force a re‑login. Others may present a success screen with a link back to the login page. This final step ensures the user knows the reset completed and can continue.
Test Matrix: Happy Path, Error Paths, Edge Cases
A systematic matrix helps you avoid missing scenarios. The table below groups tests by component, lists the objective, and gives a concise test description. Use it as a starting point; add project‑specific variations as needed.
| Component | Test Category | Objective | Test Description |
|---|---|---|---|
| Entry Point | Happy Path | Verify correct initiation | Submit a valid, registered email; expect a success message and no UI error. |
| Entry Point | Error Path – Invalid Format | Reject malformed input | Enter “plaintext”, “@”, or an email missing domain; expect inline validation error. |
| Entry Point | Error Path – Unknown Identifier | Avoid account enumeration | Submit an unregistered email; expect generic message like “If the address exists, you will receive an email.” |
| Entry Point | Edge Case – Empty Field | Handle blank submission | Leave identifier empty; expect required‑field warning. |
| Email/SMS Delivery | Happy Path | Confirm dispatch | Use a test mailbox (e.g., Mailinator) or a SMS simulator; verify receipt within 5 s. |
| Email/SMS Delivery | Error Path – Provider Failure | Graceful degradation | Simulate email service downtime (mock 500); expect user‑friendly retry or fallback message. |
| Email/SMS Delivery | Edge Case – Delayed Delivery | Tolerate latency | Introduce a 30‑second delay in the mock gateway; ensure UI shows “sending…” and does not timeout prematurely. |
| Token Validation | Happy Path | Valid token acceptance | Click the link within expiry; reset form loads with pre‑filled identifier. |
| Token Validation | Error Path – Expired Token | Reject stale links | Wait until token expiry (e.g., 15 min) then click link; expect “link expired” message. |
| message. | |||
| Token Validation | Error Path – Tampered Token | Detect manipulation | Altering the URL token by one character; expect invalid token error. |
| Token Validation | Edge Case – Token Reuse | Prevent replay | Use a valid token to reset password, then attempt to reuse same token; expect rejection. |
| Reset Password Form | Happy Path | Successful password change | Enter a strong new password, confirm, submit; expect success notification and ability to login with new cred. |
| Reset Password Form | Error Path – Weak Password | Enforce policy | Submit “12345”; expect inline strength‑violation message. |
| Reset Password Form | Error Path – Mismatch Confirmation | Catch typos | Enter “Secure!23” and confirm “Secure!24”; expect mismatch warning. |
| Reset Password Form | Edge Case – Password Reuse | Block recent passwords | Attempt to set new password equal to the last two passwords; expect policy error. |
| Post‑Reset Handling | Happy Path | Confirmation communicated | After reset, see success screen and receive confirmation email/SMS. |
| Post‑Reset Handling | Error Path – Silent Failure | Detect missing notification | Disable outbound mail in test env; submit reset; verify UI still shows success but log shows missing dispatch. |
| Post‑Reset Handling | Edge Case – Session Invalidation | Optional forced re‑login | After reset, attempt to use existing auth token; expect 401 or redirect to login. |
How to Use the Matrix
- Map to automation – Each row can become a parameterized test case (e.g., using TestNG data providers or pytest fixtures).
- Prioritize risk – Assign severity: token reuse and weak‑password acceptance are high‑severity; delayed delivery is medium.
- Track coverage – Link each row to a test‑case ID in your test‑management tool; ensure at least 95 % of rows are automated before each release.
Accessibility Considerations
Accessibility bugs often hide in plain sight because they do not cause functional failures but prevent users with disabilities from completing the flow.
Screen Reader Compatibility
- Verify that every form field has an associated
oraria-label. - Ensure dynamic messages (e.g., “We’ve sent an email”) are announced via
aria-live="polite". - Test with NVDA, VoiceOver, and TalkBack; confirm that the flow reads the purpose of each step and error messages without extra verbosity.
Keyboard Navigation
- Tab order must follow visual layout: identifier field → submit button → (if error) error message.
- All custom widgets (e.g., password strength meter) must be operable via Enter/Space.
- Ensure that modal dialogs (if used for success/failure) trap focus and allow Esc to dismiss.
Color Contrast and Visual Cues
- Contrast ratio between text and background must meet WCAG AA (≥ 4.5:1 for normal text).
- Do not rely solely on color to convey state; pair red error text with an icon or explicit text (“Invalid email format”).
- Verify that placeholder text does not become the sole indicator of required fields after user interaction.
ARIA Roles and Landmarks
- Mark the recovery page with
role="region"andaria-labelledbypointing to a heading like “Forgot your password?”. - If the flow uses a step‑wizard, each step should have
aria-steporaria‑posinset/aria‑setsize. - After a successful reset, move focus to the confirmation heading or the login link to reduce disorientation.
Automated Accessibility Checks
Integrate tools like axe-core, pa11y, or Google’s Lighthouse into CI. A sample Playwright snippet that runs axe on the reset page:
import { test, expect } from '@playwright/test';
import { injectAxe, checkA11y } from 'playwright-axe';
test.describe('Forgot password accessibility', () => {
test('page passes axe tests', async ({ page }) => {
await page.goto('/forgot-password');
await injectAxe(page);
const results = await checkA11y(page, {
// exclude known false‑positives if needed
rules: { 'color-contrast': { enabled: false } }
});
expect(results.violations).toEqual([]);
});
});
Running this on each commit catches regressions early.
Security‑Focused Tests
Security testing for password recovery goes beyond functional checks; it probes for information leakage, token weaknesses, and insufficient throttling.
Rate Limiting and Brute‑Force Protection
- Objective: Prevent an attacker from enumerating valid identifiers or guessing reset tokens.
- Test: Send 20 rapid requests with different random emails; verify that the HTTP response status remains 200 (or 429 if you implement throttling) and that the response body does not change based on existence.
- Implementation: Use a scripted loop with
curlork6:
for i in {1..20}; do
curl -s -X POST https://api.example.com/auth/forgot \
-d "email=user${i}@example.com" \
-w "%{http_code}\n"
done
If you see differing messages (e.g., “Email not found” vs. “If the address exists…”), you have an enumeration vulnerability.
Token Entropy and Expiry
- Objective: Ensure tokens are unguessable and time‑boxed.
- Test: Capture a valid reset link, extract the token portion, and attempt to brute‑force it with a dictionary of common strings (should fail).
- Test: Request a token, wait past its expiry (configured in backend, e.g., 15 min), then try to use it; expect a clear “link has expired” error.
- Code snippet (Python) to verify token length and charset:
import secrets, re
token = secrets.token_urlsafe(32) # example generation
assert len(token) >= 32
assert re.fullmatch(r'[A-Za-z0-9_-]+', token)
Information Leakage in Error Messages
- Objective: Avoid disclosing whether an identifier is registered.
- Test: Submit both a known and an unknown email; compare response bodies byte‑wise. They must be identical (or differ only in non‑user‑specific timestamps).
- Automation: Use a diff tool in your test pipeline:
const known = await request.post('/forgot').send({email: 'alice@example.com'});
const unknown = await request.post('/forgot').send({email: 'zzzunknown@example.com'});
expect(known.text).toEqual(unknown.text);
Secure Token Storage
- Objective: Tokens should be stored hashed (e.g., bcrypt) so that a DB leak does not expose usable reset codes.
- Test: After initiating a reset, query the DB (or mock) for the stored token; verify it is not plaintext.
- Pseudo‑code:
SELECT token_hash FROM password_resets WHERE email = 'test@example.com';
-- Expect output like $2b$12$... (bcrypt) not a raw UUID.
Session Invalidation Post‑Reset
- Objective: Decide whether existing sessions should be invalidated; test accordingly.
- Test: Log in, note the auth token, trigger a password reset via another device or browser, then try to use the original token; expect rejection if invalidation is enforced.
Security Test Checklist (Table)
| Security Area | Test | Pass Criteria |
|---|---|---|
| Rate limiting | 20 rapid varied emails | Uniform response, no enumeration |
| Token entropy | Token length ≥ 32 chars, URL‑safe | Regex match |
| Token expiry | Use token after expiry | “Link expired” message |
| Token reuse | Reuse token after successful reset | Rejected |
| Error message leakage | Known vs unknown email diff | Identical payloads |
| Token storage | DB lookup shows hash, not plaintext | Hash format (bcrypt, argon2) |
| Session invalidation | Pre‑reset session works post‑reset? | According to policy (usually no) |
| HTTPS enforcement | Submit over HTTP | Redirect to TLS or error |
Run these tests in a dedicated security test suite, ideally as part of a nightly pipeline or a pre‑release security gate.
Manual Testing Approaches
Even with strong automation, exploratory manual testing catches nuances that scripts miss—especially around UX, confusing copy, and unexpected device behaviors.
Exploratory Checklist
- Start from various entry points – login screen, deep link from email, bookmarked recovery URL.
- Try atypical identifiers – phone numbers with spaces, plus signs, leading zeros; emails with sub‑addressing (
+tag). - Observe copy and tone – Is the message reassuring? Does it avoid technical jargon?
- Test interruptions – Switch apps, lock screen, or receive a call while waiting for the SMS/email; does the flow survive?
- Check fallback – If email fails, does the UI offer a “Resend” or “Try SMS” option?
- Validate post‑reset state – After reset, can you log in with the new password on the same device and on a different device?
- Look for leftover debug screens – In staging builds, ensure no verbose error stacks are shown to the user.
Record observations in a shared spreadsheet with columns: *Step, Expected, Actual, Severity, Notes*. This creates a living document that grows with each release.
Session Recording and Playback
Tools like SessionStack, FullStory, or open‑source rrweb let you capture real user sessions. Filter for visits to /forgot-password and watch for:
- Users abandoning after the “sending…” spinner (possible timeout issue).
- Repeated taps on the resend button (indicating unclear feedback).
- Users copying the reset link into a notes app instead of clicking (suggests link not tappable on certain browsers).
These insights often reveal friction points that automated checks would deem “pass”.
Proxy and Network Manipulation
Using mitmproxy or Charles Proxy, you can:
- Delay responses to simulate slow email gateways and observe UI handling.
- Tamper with the token parameter in the reset link to confirm server‑side validation.
- Return a malformed JSON (e.g., missing
tokenfield) to ensure graceful error display.
Example mitmproxy script to add a 5‑second delay to the forgot‑password endpoint:
def response(flow):
if flow.request.pretty_url.endswith("/forgot-password"):
flow.response.stream = True
flow.response.headers["Cache-Control"] = "no-cache"
# artificial latency
import time
time.sleep(5)
Run the proxy, point your device or emulator to it, and execute the manual steps above.
Automated Testing Strategies
Automation provides repeatability and scalability. Choose the right layer (unit, integration, UI) for each risk area.
Unit and Service Tests
- Validator tests – Unit‑test the email/phone regex and the “account‑existence‑without‑disclosure” logic.
- Token service – Test token generation, hashing, storage, expiry, and validation in isolation.
- Rate‑limiter – Spin up a test server with the limiter middleware and fire rapid requests; assert 429 after threshold.
Integrated API Tests
Use Postman, REST Assured, or karate to drive the backend directly.
@Test
void forgotPassword_returnsSameResponseForKnownAndUnknown() {
Response known = given()
.body("{\"email\":\"alice@example.com\"}")
.post("/auth/forgot");
Response unknown = given()
.body("{\"email\":\"zzzunknown@example.com\"}")
.post("/auth/forgot");
assertEquals(known.asString(), unknown.asString());
}
Add data‑driven files (CSV or JSON) that contain variations: valid, invalid, borderline, and malicious inputs.
UI Tests with Playwright/WebDriver
For web applications, Playwright offers reliable cross‑browser automation. A basic flow:
const { test, expect } = require('@playwright/test');
test.describe('Forgot password UI', () => {
test('happy path reset', async ({ page }) => {
await page.goto('/login');
await page.click('text=Forgot password?');
await page.fill('#email', 'tester@example.com');
await page.click('button[type=submit]');
await expect(page.locator('.success-message')).toHaveText(/We’ve sent an email/i);
// simulate email extraction (in test env use a mailhook)
const link = await getResetLinkFromMailbox('tester@example.com');
await page.goto(link);
await expect(page).toHaveURL(/\/reset-password\?token=/);
await page.fill('#password', 'NewStr0ng!Pwd');
await page.fill('#confirm', 'NewStr0ng!Pwd');
await page.click('button[type=submit]');
await expect(page.locator('.success-banner')).toHaveText(/Password updated/i);
await page.goto('/login');
await page.fill('#email', 'tester@example.com');
await page.fill('#password', 'NewStr0ng!Pwd');
await page.click('button[type=submit]');
await expect(page).toHaveURL(/^\/dashboard/);
});
});
Tips for flaky tests
- Use
page.waitForResponseto wait for the email‑sending API call before proceeding. - In CI, configure a mailhook like MailSlurp or FakeSMTP that forwards messages to a webhook your test can poll.
- For mobile, mirror the same logic with Appium (see snippet below).
Mobile UI Tests with Appium (Android)
@Test
public void testForgotPasswordFlow() throws Exception {
driver.findElement(By.id("forgot_password_link")).click();
driver.findElement(By.id("email_input")).sendKeys("qa@example.com");
driver.findElement(By.id("submit_button")).click();
// wait for toast indicating email sent
new WebDriverWait(driver, 10)
.until(ExpectedConditions.visibilityOfElementLocated(By.id("toast_sent")));
// retrieve link from test mailbox (e.g., using MailSlurp API)
String resetLink = MailSlurp.waitForLatestEmail("qa@example.com")
.getLinkContaining("/reset-password");
driver.get(resetLink);
driver.findElement(By.id("password_input")).sendKeys("StrongPass!23");
driver.findElement(By.id("confirm_input")).sendKeys("StrongPass!23");
driver.findElement(By.id("reset_button")).submit();
Assert.assertTrue(driver.findElement(By.id("success_banner")).isDisplayed());
}
Data‑Driven and Property‑Based Testing
Leverage libraries like fast-check (JS) or hypothesis (Python) to generate thousands of identifier strings and assert that the backend never leaks existence information.
from hypothesis import given, strategies as st
@given(st.text(min_size=1, max_size=100))
def test_no_email_enumeration(email):
r1 = client.post("/forgot", json={"email": email})
r2 = client.post("/forgot", json={"email": email + "x"})
assert r1.text == r2.text # same generic message
Autonomous Exploration with SUSA
SUSA’s agent can be pointed at a staging build (APK or URL) and left to exercise the forgot‑password flow across its built‑in personas. Because it explores without pre‑written scripts, it often discovers:
- Dead ends where a “Resend” button is present but disabled after the first attempt, leaving the user stuck.
- Persona‑specific friction – the “elderly” persona may miss the small “Click here if you didn’t receive the email” link, while the “power‑user” persona tries to paste a token directly into the URL bar and gets a 404 because the endpoint expects a form post.
- Security gaps – the adversarial persona rapidly cycles through thousands of email addresses, exposing missing rate limits.
To run a SUSA session:
pip install susatest-agent
susatest explore --app ./app-staging.apk --personas all --duration 30m --output susa-report.json
The resulting report includes a flow map highlighting which screens were visited, success/failure rates for the reset flow, and any detected crashes or accessibility violations. You can feed the discovered UI sequences back into your test suite as new automated scenarios.
Production‑Only Edge Cases
Certain bugs only manifest when the system faces real‑world traffic, carrier filters, or third‑party service quirks. Planning for them reduces post‑release incidents.
Email Provider Throttling and Spam Filtering
- Issue: Bulk reset requests from a test suite may trigger rate limits on SendGrid, Mailgun, or SES, causing delayed or dropped emails.
- Mitigation: Use a dedicated test domain with whitelisted IPs, or mock the provider in lower environments. In production, monitor bounce and complaint rates via the provider’s dashboard.
- Test: Simulate a burst of 100 requests in a staging environment that mirrors prod SMTP settings; verify that the system queues retries and notifies the user after a configurable threshold (e.g., “We’re experiencing delays; please try again in 5 minutes”).
SMS Carrier Filtering and Number Formatting
- Issue: Some carriers strip leading zeros, reject non‑E.164 formats, or block messages containing certain keywords (e.g., “PASS”).
- Mitigation: Normalize input to E.164 (
+1XXXXXXXXXX) before invoking the gateway; provide a fallback to email if SMS fails. - Test: Use a SMS simulator like Twilio’s Debugger or Nexmo’s Mock to feed various formats and assert that the backend either normalizes correctly or returns a clear unsupported‑format message.
Locale‑Specific Date/Time and Number Formats
- Issue: Token expiry timestamps may be formatted according to server locale, causing confusion when the user sees “Expires in 0,5 h” (European decimal comma) vs. “Expires in 0.5 h”.
- Mitigation: Keep all UI timestamps in ISO 8601 or use a library that respects the user’s locale (e.g.,
Intl.DateTimeFormat). - Test: Change the device or browser locale to
fr-FR,ja-JP, andar-SA; verify that the countdown timer displays correctly and that the expiry logic still respects UTC.
Fallback Mechanisms and Multi‑Channel Delivery
- Issue: If the primary channel (email) is down, some implementations silently fail, leaving the user with no feedback.
- Mitigation: Implement a retry‑then‑alternate pattern: attempt email, if fails after two tries, offer SMS or show a “We couldn’t send email; try again later or contact support” message.
- Test: Disable the email mock, trigger a reset, and confirm that the UI presents the fallback option and that selecting it works.
Concurrent Resets and Token Collision
- Issue: A user might request a reset, then request another before using the first link, causing token invalidation confusion.
- Mitigation: Either allow multiple active tokens (store a list) or invalidate previous tokens on new request, with clear UI indicating that a new email has been sent.
- Test: Request two resets in quick succession, capture both links, and verify that only the most recent link works (or that both work if your policy permits).
Push‑Notification‑Based Recovery (Alternative to Email/SMS)
Some apps send a push notification with a one‑time code instead of email/SMS. Test:
- Ensure the app registers for push and handles token receipt when in background/foreground.
- Verify that the code is time‑limited and cannot be guessed.
- Confirm that if push fails, the app offers an alternative channel.
Checklist for Release
Before tagging a release, run through this concise list. Mark each item as Pass, Fail, or N/A and block promotion on any Fail.
| Category | Item | Pass Criteria |
|---|---|---|
| Functional | Happy‑path reset works from login page, deep link, and bookmarked URL | Success message + ability to log in with new cred |
| Functional | Invalid email format shows inline error | Field‑level validation |
| Functional | Unknown email returns generic message | No enumeration |
| Functional | Token expired yields clear error | “Link has expired” |
| Functional | Token tampering yields error | “Invalid link” |
| Functional | Weak password rejected per policy | Inline strength message |
| Functional | Password mismatch caught | Confirmation error |
| Accessibility | All form fields have associated labels or aria‑labels | Manual inspection + axe passes |
| Accessibility | Live region announces status updates | Screen‑reader test |
| Accessibility | Contrast ratio ≥ 4.5:1 for text | Colour contrast analyzer |
| Security | Rate limiting prevents enumeration (20 varied emails → same response) | Burst test |
| Security | Token length ≥ 32 chars, URL‑safe | Regex check |
| Security | Token stored as hash, not plaintext | DB inspection |
| Security | Post‑reset session invalidated per policy | Token reuse test |
| Security | Error messages identical for known/unknown emails | Diff test |
| Production | Email simulator handles 100‑burst with retry/queue | No dropped messages |
| Production | SMS formatter accepts E.164 and rejects malformed | Carrier mock |
| Production | Locale change does not break expiry display | Locale switch test |
| Production | Fallback to secondary channel when primary fails | Mock failure test |
| UX | Resend button re‑enables after cooldown | Timer test |
| Ux | Success screen provides clear next step (login link) | Manual review |
| Automation | ≥ 90 % of matrix rows have automated test coverage | Test‑management report |
| Documentation | Release notes mention any changes to reset flow | Changelog entry |
You can embed this checklist in your CI pipeline as a step that reads a JSON/YAML file and fails the build on any unmet criterion.
Takeaways and Continuous Improvement
Testing a forgot‑password flow is more than checking that a link arrives; it is a convergence of input validation, out‑of‑band communication, token security, accessibility, and real‑world service quirks. By structuring your effort around a matrix that separates happy path, error paths, edge cases, accessibility, and security, you gain a clear view of coverage and risk. Manual exploratory sessions—especially when guided by personas—catch UX friction that scripts overlook, while automated unit, API, and UI tests give you regression safety. Production‑only gotchas like carrier filtering, email throttling, and locale‑specific formatting demand dedicated monitoring and fallback strategies. Finally, a lightweight but enforceable checklist turns the matrix into a gate you can apply before every release, ensuring that the password recovery experience stays both usable and secure.
When you integrate these practices—combining thorough test matrices, disciplined manual exploration, layered automation, and vigilant production monitoring—you transform a frequently overlooked feature into a robust trust anchor for your users. Keep the matrix alive: add new rows whenever you discover a novel failure mode, retire those that are mitigated, and let your team’s shared understanding of the flow evolve alongside the application itself.
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free