Common Forgot Password Bugs and How to Catch Them

Common Forgot Password Bugs and How to Catch Them

May 06, 2026 · 19 min read · Common Issues

Common Forgot Password Bugs and How to Catch Them

Understanding Forgot Password Flows

A forgot password flow is a critical security gate that lets users regain access when they forget their credentials. The typical steps are: the user enters an identifier (email or phone), the system validates that the identifier exists, generates a one‑time reset token, sends that token via a secure channel (email, SMS), the user clicks the link or enters the token, the system verifies the token, and finally the user chooses a new password that meets policy. Each step introduces attack surface and opportunities for bugs that can lead to account takeover, denial of service, or leakage of personal data.

Because the flow is a core part of the security model that QA must verify early and often.

When the flow is broken, users experience friction: they never receive a reset email, they get a link that expires too soon, they are allowed to reset an account that does not belong to them, or they are forced to set a weak password after reset. These problems are often missed by scripted UI tests because they depend on timing, state, or external systems (mail server, SMS gateway) that are hard to mock deterministically. A combination of manual probing, automated API checks, and persona‑driven autonomous exploration is required to surface the full spectrum of defects.

In the sections that follow we examine twelve concrete bug patterns that repeatedly appear in production systems. For each pattern we explain the root cause, show how it manifests to users, give a reproducible scenario, outline detection techniques (both manual and automated), and prescribe a fix plus preventive measures. The guide ends with a test matrix, a bug/symptom/fix reference table, a short checklist, and a note on how SUSATest’s autonomous exploration catches issues that scripted tests miss.

Common Bug Pattern 1: Token Leakage in URL

Why it happens

Many implementations embed the reset token directly in the URL query string (e.g., https://app.example.com/reset?token=abc123). If the application logs URLs, shares them via Referer headers, or allows third‑party scripts to read window.location, the token can be exposed to unintended parties. Logging frameworks that capture full request URIs for debugging often write the token to plain‑text log files that are later accessible to support staff or aggregated in monitoring tools.

How it looks to users

A user who requests a reset receives an email with a link. If they click the link from a corporate network that logs all HTTP requests, the token may appear in proxy logs. An attacker with read access to those logs can hijack the account before the legitimate user even sees the email.

Reproducing the bug

  1. Trigger a forgot password request for a test account.
  2. Capture the reset email and note the URL.
  3. Check application logs (e.g., grep token /var/log/app.log) or enable debug logging on a staging server.
  4. Verify that the token appears in plain text.

Detection techniques

*Manual*: Review logging configuration and ensure that query strings are stripped or hashed before being written.

*Automated*: Add a unit test that asserts the logging middleware does not log the token parameter. Example in Python with Flask:


import logging
from flask import request

@app.before_request
def strip_sensitive_query():
    if 'token' in request.args:
        # replace with placeholder before any logging
        request.args = request.args.copy()
        request.args['token'] = '[REDACTED]'

*CI*: Run a security scanner (e.g., Bandit) that flags logging.info(request.url) patterns.

Fix and prevention

Common Bug Pattern 2: Weak or Predictable Reset Tokens

Why it happens

Developers sometimes generate tokens using a simple random number generator seeded with the current timestamp, or they use a hash of the user ID plus a static secret. Such tokens can be guessed or brute‑forced, especially when the token length is short (e.g., 6‑digit numeric codes).

How it looks to users

An attacker who knows the approximate time a user requested a reset can enumerate possible tokens and reset the password before the legitimate user acts. The victim may notice their password changed unexpectedly or be locked out after several failed attempts.

Reproducing the bug

  1. Request a reset for a known user at time T.
  2. Record the token received.
  3. Request another reset for the same user at time T+1 second.
  4. Observe if the token changes only in a predictable way (e.g., increments by 1).

Detection techniques

*Manual*: Collect a sample of tokens over a minute and compute entropy; low entropy indicates weakness.

*Automated*: Write a property‑based test that generates many tokens and checks that each token is uniformly random and of sufficient length (≥ 128 bits). Example using Hypothesis in Python:


from hypothesis import given, strategies as st

@given(st.binary(min_size=16))
def test_token_entropy(token):
    # assume generate_token() is the function under test
    assert len(generate_token()) >= 16

*CI*: Integrate a cryptographic lint rule that disallows use of random.randint for security tokens.

Fix and prevention

Common Bug Pattern 3: Missing Rate Limiting on Reset Requests

Why it happens

Teams often focus rate limiting on login endpoints but forget to protect the forgot password request endpoint. Without limits, an attacker can spam the endpoint with thousands of requests for a target email, causing email flood, denial of service on the mail provider, or exhaustion of SMS credits.

How it looks to users

The target user receives dozens or hundreds of reset emails/SMS in a short period, which can be annoying, may cause them to ignore the legitimate request, or may lead to additional charges if SMS is used.

Reproducing the bug

  1. Use a script to send POST requests to /forgot-password with the same email address 200 times within 10 seconds.
  2. Monitor the mail server logs or SMS gateway usage.
  3. Verify that no HTTP 429 (Too Many Requests) responses are returned.

Detection techniques

*Manual*: Perform a burst test with a tool like hey or wrk.

*Automated*: Add an API contract test that asserts the endpoint returns status 429 after N requests within a time window. Example using k6:


import http from 'k6/http';
import { check, sleep } from 'k6';

export let options = {
    vus: 10,
    duration: '30s',
};

export default function () {
    let res = http.post('https://api.example.com/forgot-password', JSON.stringify({ email: 'user@example.com' }), {
        headers: { 'Content-Type': 'application/json' },
    });
    check(res, { 'status is 429 after limit': (r) => r.status === 429 });
    sleep(0.1);
}

*CI*: Fail the build if the endpoint does not enforce a limit of, say, 5 requests per minute per IP.

Fix and prevention

Common Bug Pattern 4: Insecure Storage of Reset Tokens

Why it happens

Some systems store the reset token in plain text in a database column or in a cache (Redis) without encryption. If an attacker gains read access to the data store (via SQL injection, misconfigured permissions, or backup exposure), they can directly use the token to reset passwords.

How it looks to users

The user may notice their password changed without their knowledge after a data breach. The breach may be discovered later when the token appears in leaked data dumps.

Reproducing the bug

  1. Request a reset and capture the token.
  2. Query the database directly (e.g., SELECT token FROM password_resets WHERE user_id = 123;).
  3. Verify that the token appears unchanged.

Detection techniques

*Manual*: Inspect schema and data dumps for plain‑text token fields.

*Automated*: Write a test that asserts the token column is encrypted or hashed. Example using an SQL check in a test suite:


SELECT COUNT(*) FROM password_resets WHERE token NOT LIKE '%$2a$%';
-- Should return 0 if using bcrypt hash

*CI*: Include a lint rule that flags any column named token, reset_token, or similar that does not have a _hash suffix or encryption annotation.

Fix and prevention

Common Bug Pattern 5: Lack of Expiration or Too Long Expiration

Why it happens

Developers sometimes set the reset token expiration to a very long period (e.g., 30 days) to avoid user complaints about expired links, or they forget to implement expiration altogether. A long-lived token increases the window for token theft or replay attacks.

How it looks to users

A user who requests a reset but does not act for weeks may still be vulnerable if an attacker later obtains the token (e.g., from a forwarded email). Conversely, a token that expires too quickly can frustrate users who experience email delays.

Reproducing the bug

  1. Request a reset and note the timestamp embedded in the token or stored alongside it.
  2. Wait beyond the intended expiration window (e.g., 2 hours if the policy says 1 hour).
  3. Attempt to use the token; it should be rejected but is accepted.

Detection techniques

*Manual*: Check the code that creates the token for a hard‑coded TTL or missing check.

*Automated*: Write a test that advances the system clock (using libraries like freezegun in Python) and asserts that token validation fails after the TTL. Example:


import freezegun
import datetime

def test_token_expires():
    with freezegun.freeze_time("2025-01-01 12:00:00"):
        token = generate_reset_token(user_id=42)
    with freezegun.freeze_time("2025-01-01 14:05:00"):  # 2 hours later
        assert not validate_reset_token(token, user_id=42)

*CI*: Fail if the TTL is greater than a configured maximum (e.g., 1 hour).

Fix and prevention

Common Bug Pattern 6: Insufficient User Notification / No Confirmation Email

Why it happens

Some implementations only send a reset email when the token is generated but do not notify the user when a password change actually occurs. If an attacker manages to reset the password, the legitimate user may remain unaware until they try to log in.

How it looks to users

The user discovers they cannot log in with their usual password and has no recent email indicating a password change, leading to confusion and increased support load.

Reproducing the bug

  1. As an attacker, obtain a valid reset token (via token leakage or guessing).
  2. Use the token to change the password.
  3. Check the victim’s email inbox for any notification of the password change.

Detection techniques

*Manual*: Verify that the password change endpoint triggers an email or push notification.

*Automated*: Mock the email service and assert that a “password changed” notification is sent after a successful reset. Example in JavaScript with Jest:


test('sends password change email after reset', async () => {
    const sendEmail = jest.fn();
    // inject mock mailer
    await resetPasswordUsingToken(token, newPassword, { mailer: { sendEmail } });
    expect(sendEmail).toHaveBeenCalledWith(
        expect.objectContaining({ to: user.email, subject: /password changed/i })
    );
});

*CI*: Ensure the test suite includes this notification check for both email and SMS channels.

Fix and prevention

Common Bug Pattern 7: Broken Link Handling (e.g., 404 after token use)

Why it happens

After a token is consumed, some applications delete the token record but forget to update the UI state, leaving a “Reset password” page that shows a generic error or redirects to a 404 page. Users may think the link is broken and abandon the flow.

How it looks to users

The user clicks the reset link, enters a new password, submits the form, and sees an error message like “Invalid request” or is redirected to a login page with no explanation. They may retry multiple times, locking themselves out due to repeated failed attempts.

Reproducing the bug

  1. Obtain a valid reset token.
  2. Navigate to the reset page, submit a new password.
  3. Observe the HTTP response; if the server returns 404 or a validation error despite a correct token, the bug is present.

Detection techniques

*Manual*: Walk through the happy path and inspect network responses.

*Automated*: Write an end‑to‑end test that uses a real token and asserts that the final state is a successful login with the new password. Example using Cypress:


it('allows password reset and login', () => {
    cy.request('POST', '/forgot-password', { email: 'user@test.com' })
        .its('body')
        .then((body) => {
            const token = body.token;
            cy.visit(`/reset-password?token=${token}`);
            cy.get('#new-password').type('NewPass!123');
            cy.get('#confirm-password').type('NewPass!123');
            cy.get('#submit').click();
            cy.url().should('include', '/login');
            cy.contains('Login successful').should('be.visible');
        });
});

*CI*: Fail if the reset flow does not end in an authenticated session.

Fix and prevention

Common Bug Pattern 8: Misconfigured Password Strength Requirements after Reset

Why it happens

Teams often copy password policy code from the registration flow to the reset flow but forget to update the rule set (e.g., they allow reset passwords to be shorter or to omit special characters). Attackers can then set a weak password that is easy to guess, undermining the security gain of the reset.

How it looks to users

The user is allowed to choose a password like 123456 during reset, receives no error, and later the account is compromised via brute force.

Reproducing the bug

  1. Initiate a reset and obtain a token.
  2. Attempt to set a password that violates the site’s usual policy (e.g., length < 8, no complexity).
  3. Verify that the submission succeeds.

Detection techniques

*Manual*: Try a series of weak passwords and see if any are accepted.

*Automated*: Parameterized test that feeds a list of disallowed passwords and expects rejection. Example using pytest:


@pytest.mark.parametrize('pwd', ['123', 'password', 'letmein'])
def test_reset_rejects_weak_passwords(pwd):
    token = get_reset_token()
    r = client.post('/reset-password', json={'token': token, 'password': pwd})
    assert r.status_code == 400
    assert 'weak password' in r.json()['error']

*CI*: Enforce that the reset endpoint imports the same password validator used elsewhere; fail if the validator object differs.

Fix and prevention

Common Bug Pattern 9: Inadequate Logging and Monitoring

Why it happens

Logging of reset requests, token generation, and password changes is sometimes omitted or set to a low severity level, making it difficult to detect abuse campaigns or credential stuffing attempts targeting the reset flow.

How it looks to users

Users may not notice any abnormal activity, while attackers can repeatedly request resets for many accounts, harvesting tokens or causing email fatigue, without triggering alerts.

Reproducing the bug

  1. Generate a high volume of reset requests for random emails.
  2. Check the logging system (e.g., ELK, Splunk) for entries related to /forgot-password.
  3. Verify that each request logs at least the identifier, IP address, timestamp, and outcome (success or rate‑limited).

Detection techniques

*Manual*: Review logging configuration and ensure that the reset endpoint emits structured logs.

*Automated*: Write a test that sends a request and asserts that a log line containing a correlation ID is emitted. Using a test double for the logger:


def test_reset_logs_request(caplog):
    with caplog.at_level(logging.INFO):
        client.post('/forgot-password', json={'email': 'test@example.com'})
    assert any('forgot_password_request' in rec.message for rec in caplog.records)

*CI*: Fail the build if the reset handler does not call the logger at INFO level or higher.

Fix and prevention

Common Bug Pattern 10: Persona‑Driven Edge Cases (e.g., accessibility, impatient user)

Why it happens

Design teams often create a single “happy path” scenario and overlook how different user personas interact with the flow. An impatient user may repeatedly tap the “Resend email” button, an elderly user may miss the email due to small font size, a power user may try to use a password manager to autofill the token, and an accessibility‑focused user relying on screen readers may encounter missing ARIA labels.

How it looks to users

Reproducing the bug

*Impatient*: Click the resend button five times in quick succession and verify that the system does not throttle or consolidate emails.

*Elderly*: Use a browser zoom of 200% and check that all text remains readable and interactive elements are sufficiently sized.

*Power user*: Attempt to fill the token field with a password manager; note if the manager offers the suggestion.

*Accessibility*: Run an axe-core audit on the reset page and look for missing labels, insufficient contrast, or non‑keyboard‑navigable elements.

Detection techniques

*Manual*: Conduct exploratory testing with personas in mind; use browser dev tools to simulate different network speeds and device characteristics.

*Automated*:

Fix and prevention

Test Matrix: Manual vs Automated Detection Approaches

Bug PatternManual Test StepsAutomated Test TechniquesTools / Frameworks
Token leakage in URLCheck logs, proxy capturesAssert logging middleware redacts query paramsLog lint, custom unit test
Weak / predictable tokensEntropy analysis, brute‑force tryProperty‑based randomness testHypothesis, jqwik
Missing rate limitingBurst requests with hey or wrkContract test for 429 after limitk6, Gatling, Postman
Insecure token storageDirect DB query for plain textAssert column stores hash/bcryptSQL check, db‑lint
Lack / long expirationWait then try tokenTime‑travel test with frozen clockfreezegun, Timecop
Insufficient notificationObserve inbox after resetMock mailer, assert “password changed” emailJest, Mocha, MailHog
Broken link handlingSubmit new password, check responseE2E flow asserting login successCypress, Playwright, Selenium
Misconfigured strengthTry weak passwords on resetParameterized validator testpytest, JUnit, TestNG
Inadequate loggingSearch logs for reset eventsLog‑capture unit testcaplog, Logback testing
Persona edge casesVisual zoom, rapid clicks, screen reader, autofillAxe audit, rate‑limit test, autocomplete testaxe‑core, pa11y, Cypress, Jest‑axe

The table shows that many bugs are caught most reliably by automated checks that run on every commit, while manual exploratory testing remains essential for usability, persona‑specific issues, and complex timing bugs that are hard to simulate deterministically.

Bug/Symptom/Fix Reference Table

Symptom ObservedLikely Bug PatternRoot CauseRecommended Fix
Reset link contains raw token in URLToken leakage in URLToken placed in query string, logged or leaked via RefererStore token server‑side, send only reference ID; redact logs
Attacker guesses reset token quicklyWeak / predictable tokensNon‑crypto RNG, short token lengthUse secrets.token_urlsafe(32) or equivalent, store hash
Victim receives dozens of reset emailsMissing rate limitingNo limit on /forgot-password endpointEnforce per‑IP and per‑identifier limits; generic success message
Token visible in database dumpInsecure token storagePlain‑text token columnStore bcrypt/argon2 hash, delete after use
Reset link works after several hours/daysLack / long expirationMissing TTL check or excessively large TTLSet short TTL (15‑60 min), store creation timestamp, invalidate on use
No email when password actually changedInsufficient notificationMissing “password changed” alertSend confirmation email/SMS with details and revert link
After submitting new password, see 404 or generic errorBroken link handlingUI not updated after token consumptionRedirect to success page, show helpful message on expired/used token
Allows setting password “123” after resetMisconfigured strengthDifferent validator used for reset flowShare single password policy module across all flows
No trace of reset attempts in logs during attackInadequate loggingLogging level too low or omittedStructured INFO logs for request, token validation, password change
Impatient user gets multiple identical emailsPersona‑driven edge (impatient)No throttling on resend buttonRate‑limit resend attempts, show cooldown timer
Elderly user cannot read text or tap small buttonsPersona‑driven edge (elderly)Fixed‑pixel fonts, small touch targetsUse relative units, minimum 44 px touch size, WCAG contrast
Password manager does not suggest tokenPersona‑driven edge (power user)Missing autocomplete attributeAdd autocomplete="one-time-code" to token input
Screen reader reads “edit text” without contextPersona‑driven edge (accessibility)Missing labels or ARIA descriptorsProvide clear label or aria-describedby, use role="alert" for errors

This table can be copied into a team wiki or bug‑tracking template to triage reports quickly.

Checklist for Shipping a Safe Forgot Password Flow

Run through this checklist before each release candidate; treat any unchecked item as a blocker.

How Autonomous Exploration (SUSATest) Surfaces These Bugs

Scripted tests often follow a predetermined path: request reset, click link, set new password, log in. They rarely vary the timing, repeat actions, or simulate distinct user behaviors. An autonomous QA agent, however, explores the application state space by generating realistic interaction sequences driven by persona models.

When pointed at a web or mobile app, SUSATest builds a profile for each persona (curious, impatient, novice, adversarial, elderly, accessibility, power user, etc.) and lets that profile drive the exploration. For a forgot password flow, the agent might:

Because the agent does not rely on prewritten assertions, it can catch bugs that only appear under unusual combinations—such as an impatient user triggering a race condition where two reset requests overlap, or an accessibility user encountering a screen‑reader announcement that reads “button” without context. Each exploration run records the visited screens, actions taken, and any anomalies (crashes, ANRs, unexpected responses, validation failures). Over successive runs, the agent learns which paths lead to dead ends or errors and focuses future efforts on those areas, increasing the likelihood of finding subtle forgot password defects that manual testers might miss after a few passes.

Integrating SUSATest into a CI pipeline is straightforward:


pip install susatest-agent
susatest run --app-url https://staging.example.com --personas all --output results.json

The resulting JSON contains a list of discovered issues, each tagged with the responsible persona and a severity rating. Teams can import these findings into their bug tracker and prioritize fixes alongside those found by unit and contract tests.

Closing Takeaways

Forgot password flows are de

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