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
- 01Request a password reset for test@example.com; copy the full reset URL from the first email (token1) without opening it
- 02Without using token1, request a second password reset for the same account; copy the second reset URL (token2)
- 03Open token2 in the browser, set a new password, and confirm the success message appears
- 04Navigate back to the token1 URL in a new browser tab
- 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
- 01Request a password reset for test@example.com; copy token1 from the first reset email without opening it
- 02Request a second password reset for the same account; copy token2 from the second email
- 03Open the token2 URL and set a new password; confirm the success message appears
- 04Open the token1 URL in a new browser tab
- 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.