Common Forgot Password Bugs and How to Catch Them
Common Forgot Password Bugs and How to Catch Them
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
- Trigger a forgot password request for a test account.
- Capture the reset email and note the URL.
- Check application logs (e.g.,
grep token /var/log/app.log) or enable debug logging on a staging server. - 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
- Store the token server‑side and send only a reference ID (e.g., a UUID) in the URL; the server maps the ID to the token.
- If a token must be in the URL, enforce HTTPS only, set
Referrer-Policy: no-referrer, and ensure logging middleware redacts query parameters. - Rotate tokens after use and invalidate them immediately.
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
- Request a reset for a known user at time T.
- Record the token received.
- Request another reset for the same user at time T+1 second.
- 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
- Use a cryptographically secure random byte source (
secrets.token_urlsafein Python,crypto.randomBytesin Node). - Encode the token in URL‑safe Base64 and make it at least 32 bytes (256 bits) of entropy.
- Store a salted hash of the token in the database; never store the raw token.
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
- Use a script to send POST requests to
/forgot-passwordwith the same email address 200 times within 10 seconds. - Monitor the mail server logs or SMS gateway usage.
- 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
- Apply per‑IP and per‑identifier rate limits on the forgot password endpoint.
- Return a generic success message regardless of whether the identifier exists (to avoid user enumeration) while still enforcing limits.
- Log rate‑limit events for monitoring and alert on abnormal spikes.
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
- Request a reset and capture the token.
- Query the database directly (e.g.,
SELECT token FROM password_resets WHERE user_id = 123;). - 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
- Store only a salted hash (bcrypt, Argon2) of the token.
- When verifying, hash the provided token and compare to the stored hash.
- Rotate the hash after use and delete the row or mark it as used.
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
- Request a reset and note the timestamp embedded in the token or stored alongside it.
- Wait beyond the intended expiration window (e.g., 2 hours if the policy says 1 hour).
- 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
- Enforce a short, configurable expiration (15–60 minutes) and store the creation timestamp with the token.
- Invalidate tokens immediately after successful password change.
- Communicate the expiration time in the email (“This link will expire in 30 minutes”).
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
- As an attacker, obtain a valid reset token (via token leakage or guessing).
- Use the token to change the password.
- 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
- Always send a confirmation email/SMS when the password is successfully updated.
- Include details such as timestamp, IP address, and device information to help the user spot unauthorized changes.
- Provide a link to revert the change if it was not initiated by the user (requires secondary authentication).
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
- Obtain a valid reset token.
- Navigate to the reset page, submit a new password.
- 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
- After validating and consuming a token, redirect the user to a clear success page (“Password updated. You can now log in.”).
- Ensure the reset page remains available for re‑use only if the token is still valid; otherwise show a helpful message (“This link has expired or been used”).
- Keep track of used tokens and invalidate them instantly to prevent replay.
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
- Initiate a reset and obtain a token.
- Attempt to set a password that violates the site’s usual policy (e.g., length < 8, no complexity).
- 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
- Share a single password‑validation module between signup, login change, and reset flows.
- Apply the same minimum length, complexity, and dictionary checks.
- Provide real‑time feedback on the reset form so users understand the requirements.
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
- Generate a high volume of reset requests for random emails.
- Check the logging system (e.g., ELK, Splunk) for entries related to
/forgot-password. - 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
- Log each request with: endpoint, user‑provided identifier (hashed if privacy concerned), IP address, user‑agent, timestamp, and outcome.
- Emit a separate log event when a token is validated and when a password change succeeds.
- Set up alerts on spikes in reset requests per IP or per identifier (e.g., >10 requests/minute).
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
- Impatient user: receives multiple identical emails, may think the system is broken and abandon.
- Elderly user: cannot read the email or the reset page due to low contrast, leading to failed attempts.
- Power user: password manager fails to autofill the token because the input lacks
autocomplete="one-time-code". - Accessibility user: screen reader announces “edit text” without describing the purpose, causing confusion.
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*:
- Use
jest-axeorpa11yin CI to assert no WCAG AA violations on the reset page. - Write a Cypress test that simulates rapid clicks and asserts that the email sending rate is limited.
- Use a plugin like
cypress-react-selectorto test autofill behavior.
Fix and prevention
- Apply rate limiting to the “Resend email” action (e.g., one resend per 2 minutes).
- Ensure WCAG AA contrast ratios (minimum 4.5:1 for normal text) and scalable UI (use relative units).
- Add
autocomplete="one-time-code"to token inputs and appropriatearia-labeloraria-describedbyattributes. - Provide clear, concise error messages that are announced by screen readers (use
role="alert").
Test Matrix: Manual vs Automated Detection Approaches
| Bug Pattern | Manual Test Steps | Automated Test Techniques | Tools / Frameworks |
|---|---|---|---|
| Token leakage in URL | Check logs, proxy captures | Assert logging middleware redacts query params | Log lint, custom unit test |
| Weak / predictable tokens | Entropy analysis, brute‑force try | Property‑based randomness test | Hypothesis, jqwik |
| Missing rate limiting | Burst requests with hey or wrk | Contract test for 429 after limit | k6, Gatling, Postman |
| Insecure token storage | Direct DB query for plain text | Assert column stores hash/bcrypt | SQL check, db‑lint |
| Lack / long expiration | Wait then try token | Time‑travel test with frozen clock | freezegun, Timecop |
| Insufficient notification | Observe inbox after reset | Mock mailer, assert “password changed” email | Jest, Mocha, MailHog |
| Broken link handling | Submit new password, check response | E2E flow asserting login success | Cypress, Playwright, Selenium |
| Misconfigured strength | Try weak passwords on reset | Parameterized validator test | pytest, JUnit, TestNG |
| Inadequate logging | Search logs for reset events | Log‑capture unit test | caplog, Logback testing |
| Persona edge cases | Visual zoom, rapid clicks, screen reader, autofill | Axe audit, rate‑limit test, autocomplete test | axe‑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 Observed | Likely Bug Pattern | Root Cause | Recommended Fix |
|---|---|---|---|
| Reset link contains raw token in URL | Token leakage in URL | Token placed in query string, logged or leaked via Referer | Store token server‑side, send only reference ID; redact logs |
| Attacker guesses reset token quickly | Weak / predictable tokens | Non‑crypto RNG, short token length | Use secrets.token_urlsafe(32) or equivalent, store hash |
| Victim receives dozens of reset emails | Missing rate limiting | No limit on /forgot-password endpoint | Enforce per‑IP and per‑identifier limits; generic success message |
| Token visible in database dump | Insecure token storage | Plain‑text token column | Store bcrypt/argon2 hash, delete after use |
| Reset link works after several hours/days | Lack / long expiration | Missing TTL check or excessively large TTL | Set short TTL (15‑60 min), store creation timestamp, invalidate on use |
| No email when password actually changed | Insufficient notification | Missing “password changed” alert | Send confirmation email/SMS with details and revert link |
| After submitting new password, see 404 or generic error | Broken link handling | UI not updated after token consumption | Redirect to success page, show helpful message on expired/used token |
| Allows setting password “123” after reset | Misconfigured strength | Different validator used for reset flow | Share single password policy module across all flows |
| No trace of reset attempts in logs during attack | Inadequate logging | Logging level too low or omitted | Structured INFO logs for request, token validation, password change |
| Impatient user gets multiple identical emails | Persona‑driven edge (impatient) | No throttling on resend button | Rate‑limit resend attempts, show cooldown timer |
| Elderly user cannot read text or tap small buttons | Persona‑driven edge (elderly) | Fixed‑pixel fonts, small touch targets | Use relative units, minimum 44 px touch size, WCAG contrast |
| Password manager does not suggest token | Persona‑driven edge (power user) | Missing autocomplete attribute | Add autocomplete="one-time-code" to token input |
| Screen reader reads “edit text” without context | Persona‑driven edge (accessibility) | Missing labels or ARIA descriptors | Provide 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
- [ ] Token is generated with a cryptographically secure RNG (≥ 128‑bit entropy).
- [ ] Token is never placed in plain‑text URLs; if used, URL is HTTPS‑only and Referrer‑Policy is set.
- [ ] Token hash (bcrypt/argon2) is stored; raw token never written to logs or DB.
- [ ] Reset endpoint enforces rate limits (e.g., 5 requests per identifier per hour).
- [ ] Token expiration is short (15‑60 minutes) and validated server‑side.
- [ ] After successful reset, a confirmation email/SMS is sent with details and optional revert link.
- [ ] Reset page shows clear success or helpful error messages; no 404 after token use.
- [ ] Password strength validator is identical to that used for registration and password change.
- [ ] Logging captures request identifier (hashed), IP, timestamp, and outcome at INFO level.
- [ ] Alerts trigger on abnormal reset volumes (e.g., >10 requests/min per IP).
- [ ] UI meets WCAG AA: sufficient contrast, scalable text, accessible labels, keyboard navigation.
- [ ] Token input includes
autocomplete="one-time-code"for password‑manager support. - [ ] Resend‑email action is rate‑limited and shows a cooldown timer.
- [ ] All automated tests (unit, contract, E2E) covering the above are in the CI pipeline and pass on every commit.
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:
- Curious persona: try every link on the reset page, inspect network requests, notice that the token appears in the URL query string and flag it for review.
- Impatient persona: repeatedly tap the “Resend email” button every second, observe that the system sends an email each time, and detect the missing rate limit.
- Elderly persona: increase the system font size to 200%, attempt to tap the small “Submit” button, and record that the touch target is below the recommended size, surfacing a WCAG issue.
- Adversarial persona: fuzz the token field with random strings, very long inputs, and SQL‑injection patterns, looking for improper validation or error messages that leak internal details.
- Accessibility persona: run an axe‑core scan on each visited screen and collect violations such as missing labels on the token input or insufficient contrast on the success message.
- Power user: attempt to autofill the token using a password‑manager‑style input and verify that the browser offers the suggestion; if not, log the missing
autocompleteattribute.
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