Authentication Bugs

Multiple Password Reset Tokens Valid Simultaneously

When a user requests a second password reset link, the server issues a new token without invalidating the first one. Both tokens remain valid until they expire or are used. An attacker who previously captured the first reset email — via phishing, a shared inbox, or forwarded mail — can still use that token to take over the account even after the user issued a fresh request.

HighBeginnerSecurity testingManual testingAPI testing

// UNDERSTAND

// Symptoms

  • After requesting a second reset link for test@example.com, the first link still shows the password reset form
  • POST /api/auth/reset-password succeeds with either token — both return 200 OK
  • No 'This link has been superseded' or 'A newer reset link has been issued' error appears when the first token is used after the second was requested
  • Querying the token table reveals multiple rows with is_used = false and expires_at in the future for the same user_id
  • The authentication log contains no event recording that the first token was invalidated when the second was issued

// Root Cause

  • When the forgot-password handler generates a new token, it inserts the new row into the reset_tokens table without first deleting or soft-expiring existing unexpired tokens for the same user_id. Multiple active token rows coexist for the same account.
  • No unique constraint or application-level rule limits a user to one active password reset token at a time. The database permits any number of concurrent valid tokens for the same account without conflict.

// Where It Appears

  • Password reset flows in any authenticated web application
  • Account recovery pages where users can submit multiple reset requests
  • Applications where users request a new reset link because the first did not arrive, resulting in multiple valid tokens accumulating
  • Shared or team inboxes where earlier reset emails remain accessible after a new one is sent

// REPRODUCE & TEST

// How to Reproduce

  1. 01Request a password reset for test@example.com; copy the full reset URL from the first email (token1) without opening it
  2. 02Without using token1, request a second password reset for the same account; copy the second reset URL (token2)
  3. 03Open token2 in the browser, set a new password, and confirm the success message appears
  4. 04Navigate back to the token1 URL in a new browser tab
  5. 05If the token1 reset form loads and accepts a new password, both tokens are valid simultaneously — the bug is confirmed

// Test Data Needed

  • A test user account test@example.com with a working test email inbox (Mailtrap, Mailhog, or real email)
  • The ability to copy two reset URLs before either is opened
  • A second browser tab to replay the first URL after the second has been used

// Manual Testing Ideas

  • Request two reset links in quick succession; open the second, reset the password, then try the first — confirm it is rejected
  • Request a reset link, wait without using it, then request another; confirm the first link now shows an error
  • Check the token table directly (if accessible) to confirm only one unexpired row exists per user_id at any time
  • Test whether the first link shows a specific 'superseded' error versus a generic 'invalid or expired' error — the former is better UX
  • Verify that requesting a new reset link also logs a security event noting the earlier token was invalidated

// API Testing Ideas

  • POST /api/auth/forgot-password with { "email": "test@example.com" } to generate token1; extract token1 from the test inbox
  • POST /api/auth/forgot-password again for the same email to generate token2; extract token2
  • POST /api/auth/reset-password with token1 and a new password
  • Assert the response is 400 Bad Request or 410 Gone — not 200 — because token1 should have been superseded when token2 was issued
  • POST /api/auth/reset-password with token2 and assert the response is 200 OK — only the most recent token should work

// Automation Idea

Send two consecutive POST /api/auth/forgot-password requests for test@example.com. Extract both reset tokens from the test inbox. Submit POST /api/auth/reset-password with token1 and assert the response is 400 or 410 (token superseded). Then submit with token2 and assert it returns 200. If token1 also returns 200, the test fails — both tokens are valid simultaneously.

// Expected Result

Requesting a new password reset link immediately invalidates all previously issued unexpired tokens for the same account. Only the most recently issued token can be used to reset the password.

// Actual Result (Example)

After requesting two reset links for test@example.com, both token1 and token2 load the password reset form and accept a new password submission. POST /api/auth/reset-password returns 200 OK for both tokens.

// REPORT IT

Example Bug Report

Title
Two valid password reset tokens exist simultaneously for test@example.com after two reset requests
Severity
High
Environment
Staging environment Chrome 124 test@example.com — Mailtrap test inbox Standard user account
Steps to Reproduce
  1. 01Request a password reset for test@example.com; copy token1 from the first reset email without opening it
  2. 02Request a second password reset for the same account; copy token2 from the second email
  3. 03Open the token2 URL and set a new password; confirm the success message appears
  4. 04Open the token1 URL in a new browser tab
  5. 05Enter a new password using token1 and submit
Expected Result
Token1 shows an error such as 'This link has been superseded' or redirects to the login page.
Actual Result
Token1 loads the password reset form normally and accepts a new password. POST /api/auth/reset-password returns 200 OK for token1, resetting the password a second time with the same link.
Impact
An attacker who previously captured the first reset email — via phishing, a shared inbox, or email forwarding — can use the old token to take over the account even after the legitimate user requested a new link, potentially in response to discovering the compromise.

// RELATED