How to Test Forgot Password on Web (Complete Guide)

Forgot‑password functionality is one of the few user‑facing paths that directly touches credential recovery, yet it is often treated as an after‑thought in test plans. When it fails, users cannot rega

April 11, 2026 · 18 min read · How-To Guides

Why Forgot Password Testing Matters

Forgot‑password functionality is one of the few user‑facing paths that directly touches credential recovery, yet it is often treated as an after‑thought in test plans. When it fails, users cannot regain access to their accounts, support tickets spike, and trust erodes. From a security standpoint, a weak reset flow can become an entry point for account takeover, credential stuffing, or information leakage. In production, subtle bugs—such as a token that lives too long, a rate‑limit that is too permissive, or an email that lands in spam—only surface under real‑world load or with specific user personas. A thorough test strategy therefore protects both usability and the integrity of the authentication system.

Forgot Password Flow Overview

Most web implementations follow a similar pattern, though details vary by product and compliance requirements.

Typical Steps

  1. Entry point – user clicks a “Forgot password?” link on the login page.
  2. Identification – user supplies an identifier (email, username, or phone).
  3. Validation – backend checks whether the identifier exists and is eligible for reset (not locked, not deactivated).
  4. Token generation – a cryptographically random, single‑use token is created and stored with an expiry timestamp.
  5. Out‑of‑band delivery – token is sent via email, SMS, or a push notification.
  6. User consumption – user clicks the link or enters the token in a reset form.
  7. Password update – user provides a new password that meets policy; backend validates token, updates credential hash, and invalidates any existing sessions.
  8. Confirmation – user sees a success message and is redirected to login or a post‑reset landing page.

Variations

Backend Considerations

Understanding these pieces helps you map test conditions to concrete implementation details.

Comprehensive Test Matrix

Below is a granular matrix that covers the functional, error, edge, accessibility, and security dimensions of a forgot‑password flow. Each row can be turned into a test case; the columns indicate the suggested priority (P0 = blocking, P1 = high, P2 = medium) and the typical severity if the defect reaches production.

#Test CategoryDescriptionPriorityExpected ResultFailure Severity
1Happy Path – EmailSubmit valid registered email → receive reset link → click → set new password → login with new credsP0Success message, able to loginCritical
2Happy Path – UsernameSame as 1 but using username as identifierP0Same as email pathCritical
3Happy Path – Phone/SMSSubmit valid phone number → receive OTP → enter OTP → set new password → loginP0SuccessCritical
4Error – Non‑existent IDSubmit email/username that is not in the systemP1Generic message (“If the account exists, you will receive…”) – no enumerationMedium
5Error – Malformed InputSubmit email without @, extremely long string, SQL‑injection payloadP1Client‑side validation error or server‑side 400 with generic messageLow
6Error – Already ResetSubmit identifier for an account that already has a pending reset token (token not yet expired)P1System either rejects with “A reset request is already in progress” or silently ignores and sends a new token (both acceptable if documented)Low
7Edge – Token ExpiryRequest reset, wait > expiry time, then attempt to use the linkP1System shows “Link has expired or is invalid”Medium
8Edge – Token ReuseUse the same token twice to reset passwordP1Second attempt fails with invalid tokenMedium
9Edge – Concurrent RequestsTrigger two reset requests in quick succession for same IDP2Either both succeed with distinct tokens or second is throttled; no account lockoutLow
10Edge – Rate LimitSend N reset requests (N = configured limit +1) from same IP or identifierP1Subsequent requests receive 429 or generic “Try again later”Medium
11Edge – Special CharactersIdentifier contains Unicode, spaces, plus signs, or sub‑addressing (e.g., user+tag@example.com)P2System accepts and delivers email correctlyLow
12Accessibility – Keyboard NavigationTab through the forgot‑password form, activate submit with Enter, verify focus order and visible focus ringsP1All controls reachable and operable via keyboardMedium
13Accessibility – Screen ReaderUse NVDA/JAWS or VoiceOver to announce labels, error messages, and live regionsP1Meaningful announcements, ARIA‑labelled inputs, descriptive error textMedium
14Accessibility – Color ContrastVerify WCAG AA contrast for text, buttons, and links on the reset pageP1Contrast ratio ≥4.5:1 (normal text)Low
15Security – Token EntropyInspect generated token (via test hook or logs) for length and randomnessP0Token ≥128 bits, cryptographically random (no predictable patterns)Critical
16Security – Token StorageVerify that stored token is hashed (e.g., bcrypt) and not recoverable from DB dumpsP0Only hash stored; plaintext never appears in logs or responsesCritical
17Security – Information LeakageAttempt to enumerate valid accounts via response timing or message differencesP1No discernible difference in status code, timing, or message body between existing and non‑existing IDsMedium
18Security – Rate Limit BypassTry to reset using different identifiers but same IP, or via proxies, to see if limit is per‑IP onlyP1Limit enforced per identifier *and* per IP (or as per policy)Medium
19Security – CSRF on Reset FormSubmit reset form without valid CSRF tokenP1Request rejected (403) or requires tokenMedium
20Security – ClickjackingAttempt to frame the reset page in an iframe from another originP1Page protected with X‑Frame‑Options: DENY or SAMEORIGIN, or CSP frame‑ancestorsLow
21Privacy – Email ContentVerify that reset email contains only the token link, no personal data (e.g., username, last login)P2Email body minimal, no PII beyond what is needed for the linkLow
22Privacy – LoggingEnsure logs never capture the plain token or the new passwordP1Logs show only hashed token or placeholderMedium
23I18n – Language SwitchChange UI language, go through reset flow, verify all text and placeholders translate correctlyP2All UI elements reflect selected languageLow
24Compliance – Consent CheckIf GDPR‑style consent is required for processing email, confirm that consent checkbox is enforcedP2Flow blocked until consent givenLow

How to use the matrix

Severity/Priority Reference Table

SeverityDescriptionTypical Fix Timeline
CriticalBlocks core authentication; enables account takeover or denial of serviceImmediate (within 24 h)
HighSeverely impacts usability or security but does not allow direct compromiseWithin 3 sprints
MediumCauses user frustration, support overhead, or minor policy deviationNext release
LowCosmetic, rarely observed, or only relevant under exotic conditionsBacklog

Manual Testing Forgot‑ Complete Guide

Manual Testing Approach

Manual Approach

Approach

A disciplined manual session can uncover subtle issues that automated scripts miss, especially when human perception of wording, visual layout, or timing is involved.

Environment Setup

  1. Browser profile – use a clean profile (no extensions, cache disabled) to avoid interference from password managers or ad‑blockers.
  2. Network throttling – simulate 3G or offline moments via DevTools → Network → throttling to see how the UI behaves when the email service is slow or unavailable.
  3. Mail capture – set up a local SMTP sink (e.g., MailHog, Papercut) or a test‑only mailbox (e.g., a Gmail alias with +test) to inspect the exact reset email without spamming real users.
  4. Backend hooks – if available, enable a test endpoint that returns the generated token or its hash, allowing you to verify expiry and storage without parsing emails.
  5. Accessibility tools – install axe core browser extension, and have a screen‑reader (NVDA on Windows, VoiceOver on macOS) ready for quick checks.

Step‑by‑Step Checklist

StepActionObservation Points
1Navigate to login page, locate “Forgot password?” linkLink visible, keyboard focusable, aria‑label descriptive
2Click link → lands on reset request pageURL changes appropriately, no redirect loops
3Enter a valid registered emailField accepts input, shows inline validation if any
4Submit formLoading indicator appears, no page reload unless intended
5Check mail captureEmail arrives within expected time (≤30 s), contains only token link, subject line generic
6Click token link (or copy‑paste)Browser navigates to reset password page, token appears in URL or hidden field
7Enter new password meeting policyPassword strength meter updates correctly, error messages for policy violations
8Submit new passwordSuccess toast/message, session invalidated (you are logged out)
9Attempt login with old passwordLogin fails with “Invalid credentials”
10Login with new passwordAccess granted, redirected to expected post‑login page
11Repeat steps using username/phone as identifierSame success flow
12Try submitting non‑existent IDGeneric message, no indication whether account exists
13Attempt to submit malformed email (e.g., “test@@example”)Inline validation error or server 400 with generic reply
14Trigger multiple rapid requests (e.g., via curl)Observe rate‑limit responses (429) or generic throttling
15Use screen reader to navigate the formAll labels announced, live region updates for success/error
16Disable CSS or use high‑contrast modeEnsure readability, focus outlines visible
17Change browser language, repeat flowAll static text translates, placeholders update
18Inspect network requestsVerify token is sent over HTTPS only, no token in query string of subsequent requests after reset
19Check DevTools → Application → StorageConfirm that any temporary token stored in localStorage/sessionStorage is cleared after reset
20Review server‑side logs (if accessible)Ensure no plain token or password appears; only hashed token or audit entry

Common Pitfalls to Spot

By following this checklist, you gain confidence that the flow works for the majority of users while also catching the subtle defects that only a human eye (or a well‑crafted exploratory session) can notice.

Automated Testing Approaches

Automation provides repeatability and fast feedback, especially for regression and continuous integration. Below are strategies tailored to the web forgot‑password flow.

Unit / API Tests for Backend

If your application exposes a dedicated API endpoint for password reset (e.g., POST /api/v1/auth/forgot), test it directly:


// Example using Jest and supertest
const request = require('supertest');
const app = require('../src/app'); // express instance

describe('Forgot password API', () => {
  it('returns 200 for known email', async () => {
    const res = await request(app)
      .post('/api/v1/auth/forgot')
      .send({ email: 'user@example.com' })
      .expect(200);
    expect(res.body.message).toMatch(/check your inbox/i);
  });

  it('does not reveal account existence', async () => {
    const res1 = await request(app)
      .post('/api/v1/auth/forgot')
      .send({ email: 'known@example.com' })
      .expect(200);
    const res2 = await request(app)
      .post('/api/v1/auth/forgot')
      .send({ email: 'unknown@nonexistent.com' })
      .expect(200);
    // Both responses should be indistinguishable
    expect(res1.text).toEqual(res2.text);
  });

  it('enforces rate limit per IP', async () => {
    for (let i = 0; i < 5; i++) {
      await request(app)
        .post('/api/v1/auth/forgot')
        .send({ email: `test${i}@example.com` })
        .expect(200);
    }
    const res = await request(app)
      .post('/api/v1/auth/forgot')
      .send({ email: 'ratelimit@example.com' })
      .expect(429);
    expect(res.body.error).toMatch(/too many requests/i);
  });
});

These tests validate business logic without launching a browser, making them fast and reliable for CI.

UI Tests with Selenium / WebDriver

When you need to assert DOM changes, visual cues, or email delivery, a UI test is appropriate. Below is a Python‑ Selenium example that uses a test mailbox (MailHog) to capture the reset link.


import time
import re
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import requests

MAILHOG_API = "http://localhost:8025/api/v2/messages"

def get_latest_email():
    resp = requests.get(MAILHOG_API).json()
    items = resp.get('items', [])
    if not items:
        return None
    latest = items[0]
    # Assume the email contains a link with token
    body = latest['Content']['Body']
    match = re.search(r'https?://[^\s"]+reset[^\s"]+', body)
    return match.group(0) if match else None

def test_forgot_password_flow():
    driver = webdriver.Chrome()
    wait = WebDriverWait(driver, 15)
    try:
        driver.get("https://app.example.com/login")
        # Click forgot password link
        wait.until(EC.element_to_be_clickable((By.LINK_TEXT, "Forgot password?"))).click()
        # Enter email
        email_input = wait.until(EC.presence_of_element_located((By.NAME, "email")))
        email_input.send_keys("qa-test@example.com")
        email_input.submit()
        # Wait for success toast
        wait.until(EC.visibility_of_element_located((By.CLASS_NAME, "toast-success")))
        # Poll MailHog for the email (max 30s)
        deadline = time.time() + 30
        reset_url = None
        while time.time() < deadline:
            reset_url = get_latest_email()
            if reset_url:
                break
            time.sleep(2)
        assert reset_url, "Reset email not received"
        # Open reset link
        driver.get(reset_url)
        # Set new password
        pwd_input = wait.until(EC.presence_of_element_located((By.NAME, "password")))
        pwd_input.send_keys("NewP@ssw0rd!")
        confirm_input = driver.find_element(By.NAME, "password_confirm")
        confirm_input.send_keys("NewP@ssw0rd!")
        driver.find_element(By.XPATH, "//button[@type='submit']").click()
        # Expect success message
        wait.until(EC.text_to_be_present_in_element((By.TAG_NAME, "p"),
                                                    "Password updated"))
        # Logout and login with new creds
        driver.get("https://app.example.com/logout")
        driver.get("https://app.example.com/login")
        wait.until(EC.presence_of_element_located((By.NAME, "email"))).send_keys("qa-test@example.com")
        driver.find_element(By.NAME, "password").send_keys("NewP@ssw0rd!")
        driver.find_element(By.XPATH, "//button[@type='submit']").click()
        assert "Dashboard" in driver.title
    finally:
        driver.quit()

Why this works

Cypress Example

Cypress excels at testing within the same origin; for cross‑origin email handling you can stub the mail request or use a third‑party service like Mailosaur.


// cypress/integrations/forgot_password.spec.js
describe('Forgot password flow', () => {
  const testEmail = 'cypress-test@example.com';

  beforeEach(() => {
    cy.visit('/login');
    cy.get('a[href="/forgot"]').click();
  });

  it('shows success message after submitting known email', () => {
    cy.get('input[name="email"]').type(testEmail);
    cy.get('button[type="submit"]').click();
    cy.contains(/if the account exists, you will receive an email/i)
      .should('be.visible');
  });

  it('allows password reset via email link (using Mailosaur)', () => {
    // Assume Mailosaur provides an API to fetch the latest email
    cy.task('getResetLink', { email: testEmail }).then((link) => {
      expect(link).to.be.a('string');
      cy.visit(link);
      cy.get('input[name="password"]').type('NewP@ssw0rd!{enter}');
      cy.get('input[name="password_confirm"]').type('NewP@ssw0rd!{enter}');
      cy.contains('Password updated successfully').should('be.visible');
      // Logout and login with new creds
      cy.clearCookies();
      cy.visit('/login');
      cy.get('input[name="email"]').type(testEmail);
      cy.get('input[name="password"]').type('NewP@ssw0rd!{enter}');
      cy.url().should('include', '/dashboard');
    });
  });
});

The cy.task command would call a Node script that queries Mailosaur’s REST API for the latest message matching the sender/subject and extracts the magic link.

Playwright Example (TypeScript)

Playwright offers built‑in network interception and multiple browser contexts, useful for simulating rate limits.


// tests/forgot-password.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Forgot password', () => {
  test('rate limit blocks after N attempts', async ({ page }) => {
    await page.goto('/login');
    await page.click('text=Forgot password?');

    const emailInput = page.locator('input[name="email"]');
    const submitBtn = page.locator('button[type="submit"]');

    // Simulate 5 rapid requests (limit = 4)
    for (let i = 0; i < 5; i++) {
      await emailInput.fill(`test${i}@example.com`);
      await submitBtn.click();
      // Wait for either toast or error
      await page.waitForTimeout(500);
    }

    const lastResponse = await page.locator('.toast-error').textContent();
    expect(lastResponse?.toLowerCase()).toContain('too many requests');
  });

  test('token expires after set time', async ({ page }) => {
    // ... }) => {
      // request reset
      await page.goto('/login');
      await page.click('text=Forgot password?');
      await page.fill('input[name="email"]', 'user@example.com');
      await page.click('button[type="submit"]');

      // intercept the email sending endpoint to capture token
      let token: string | null = null;
      await page.route('**/api/v1/auth/forgot', async route => {
        const resp = await route.fetch();
        const json = await resp.json();
        token = json.token; // assuming test backend returns token
        await route.fulfill({ resp });
      });

      // wait for token capture
      await page.waitForTimeout(1000);
      expect(token).not.toBeNull();

      // simulate waiting past expiry (e.g., 10 min) using fake timer
      // if your app uses JS Date, you can override via page.evaluate
      await page.evaluate(() => {
        // shift system time forward by 11 minutes
        const originalDate = Date;
        // @ts-ignore
        Date = class extends originalDate {
          constructor() { super(); }
          now() { return super.now() + 11 * 60 * 1000; }
        };
      });

      // now try to use the token link
      await page.goto(`/reset-password?token=${token}`);
      await page.fill('input[name="password"]', 'NewP@ssw0rd!');
      await page.fill('input[name="password_confirm"]', 'NewP@ssw0rd!');
      await page.click('button[type="submit"]');
      const errorMsg = await page.locator('.form-error').textContent();
      expect(errorMsg?.toLowerCase()).toContain('expired or invalid');
    });
  });
});

Key takeaways for automation

Tooling and Frameworks Specific to Web

Beyond generic Selenium/Cypress/Playwright, several niche tools add value when testing forgot‑password flows.

CategoryToolWhat it adds for forgot‑passwordExample usage
Accessibilityaxe‑core (npm, Chrome/Firefox extension)Automated WCAG scans on the reset request and reset password pages; can be run in CI via axe-playwright or axe-selenium.npx axe-playwright ./tests/forgot_password.spec.js --tags wcag2aa
SecurityOWASP ZAP (Docker or desktop)Active scanner that can probe for CSRF, information leakage, and weak token generation; includes an API to automate scans in CI.zap-baseline.py -t https://app.example.com/forgot -r zap-report.html
Email TestingMailHog, Mailosaur, EthereaCaptures outbound email, provides API to fetch latest message, extract links, and assert content.`curl http://localhost:8025/api/v2/messagesjq '.items[0].Content.Body'`
Token InspectionJWT Decoder (if tokens are JWT) or custom test endpointAllows you to verify token claims (expiry, audience) without relying on email delivery.Add a dev endpoint GET /debug/token/:id that returns decoded payload (only in test env).
Performancek6 or ArtillerySimulates burst of reset requests to validate rate‑limiting and backend throughput under load.k6 run --vus 50 --duration 2m script.js where script hits /forgot endpoint.
Visual RegressionApplitools, PercyDetects unintended UI shifts on the reset pages after a CSS or layout change.applitools eyes.open(driver, "Forgot Password", "reset page")
Test Data ManagementFactory Boy (Python) or fixture‑factory (JS)Generates realistic user records with varied email formats, ensuring you test edge cases like plus‑addressing or international domains.UserFactory.create_batch(10, email=fake.unique().safe_email())

Integrating these tools into a CI pipeline creates a safety net that catches regressions before they reach production. For instance, a typical pipeline could be:

  1. Unit tests (Jest/Mocha) – run on every commit.
  2. Static security scan (ZAP baseline) – run nightly.
  3. UI test suite (Playwright) – run on PRs against a preview environment.
  4. Accessibility scan (axe) – run on UI test completion.
  5. Load test (k6) – run weekly against staging.
  6. Email verification (MailHog) – embedded in UI test steps.

When any stage fails, the build is blocked, and the responsible team receives a detailed report.

Autonomous, Persona‑Driven Exploration with SUSA

While scripted tests cover the happy path and known edge cases, real users exhibit a wide variety of behaviors that static scripts never anticipate. An autonomous QA platform like SUSA can explore the application without pre‑written steps, using simulated personas to surface hidden defects.

How SUSA Discovers Forgot Password Flows

  1. Crawl & Map – Upon receiving the web URL, SUSA builds a graph of reachable states by interacting with links, buttons, and form fields. The “Forgot password?” link is treated like any other UI element; if it is present, the crawler follows it.
  2. Persona Injection – Each explored state is exercised by a set of virtual users, each embodying a distinct behavior profile:
  1. Observation & Assertion – SUSA monitors HTTP responses, DOM mutations, console errors, and network timings. It automatically checks for:
  1. Learning Loop – After each run, SUSA remembers which screens led to dead ends (e.g., a form that never shows a success message) and which inputs caused errors. Subsequent runs prioritize unexplored paths, increasing coverage over time.

What SUSA Finds That Scripts Miss

Example Output (condensed)


Run #42 – Persona: Impatient
- Submitted reset request 3 times within 800ms.
- Backend responded with 429 on the 2nd attempt (as expected).
- UI showed generic “Please wait” toast but did not disable the submit button,
  allowing a 4th click that resulted in a 500 Internal Server Error.
  → Logged: duplicate submission race condition.

Run #57 – Persona: Adversarial
- Entered email: `test@example.com'<script>alert('xss')</script>`
- Response contained the unsanitized string in the error message:
  “Invalid email: test@example.com'<script>alert('xss')</script>”.
  → XSS vector identified (CWE‑79).

Run #68 – Persona: Elderly (keyboard only)

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