Authentication · Methodology · 6 min read
The password reset token that survives an email change
The class only shows up when you change the account underneath a live token and then try to use it, which is why testing tokens in isolation never finds it.
Almost every team tests password reset tokens. They check that the token expires, that it is single-use, that it is long enough to resist guessing, and that it is bound to the right account. All of that is correct, and all of it tests the token as an isolated object.
The severe bugs in this area are not properties of the token. They are properties of the relationship between the token and an account that can change while the token is outstanding.
The shape of the bug
A reset token is issued to an email address at a moment in time. Between issuing and consuming it, the account underneath it can change: the email address can be updated, the password can be changed, multi-factor can be enrolled or removed, a session can be revoked, the account can be deactivated, a role can change, an organisation membership can be transferred.
The question that finds bugs is not “is this token valid?” but “should this token still be valid after that?”, and for most of those transitions, the correct answer is no.
The classic instance: an attacker with temporary access to an account requests a password reset, then the legitimate owner regains control and changes the email address. If the outstanding token is still honoured, the attacker resets the password on an account that no longer belongs to the address the token was issued to.
Test it as a matrix, not a checklist
Enumerate every security-relevant artefact the system issues, and every state transition the account can undergo. Then test the cells: issue the artefact, perform the transition, attempt to consume the artefact. Expect rejection.
| Artefact | Transition performed after issuing | Expected on consume |
|---|---|---|
| Password reset token | Email address changed | Rejected |
| Password reset token | Password changed by another route | Rejected |
| Email verification link | Email address changed again | Rejected |
| Session cookie / JWT | Password changed | Rejected |
| Session cookie / JWT | MFA enrolled or reset | Re-authentication required |
| Refresh token | Logout, or “sign out everywhere” | Rejected |
| API key / PAT | Owner removed from organisation | Rejected |
| Team invitation link | Invitation revoked, or role downgraded | Rejected |
| OAuth authorisation code | Account password changed | Rejected |
| Magic sign-in link | A newer link requested | Older link rejected |
Why it gets missed
Because the two halves live in different parts of the codebase and usually in different tickets. The token logic is correct. The email change logic is correct. Nobody owns the interaction, and no functional test covers it because no user would ever produce that sequence by accident.
It also survives code review for the same reason: each diff looks right on its own. The defect only exists in the composition.
What to do about it
On the engineering side, the durable fix is to bind invalidation to the state, not to the flow. Derive a value into every token, such as a password hash fragment, an incrementing security stamp or a credentials-changed timestamp, and validate it at consume time. Then any transition that bumps the stamp invalidates everything outstanding, including the artefacts nobody remembered to enumerate.
On the testing side, the matrix above takes a couple of hours to walk and finds a critical roughly as often as any technique we run. It is the cheapest high-severity class in the catalogue, and it is missed on most engagements because it does not look like a vulnerability class. It looks like two features.
In short
- Point 1
- Test artefacts across state transitions, not as isolated primitives.
- Point 2
- Issue → transition → consume. Expect rejection; investigate every acceptance.
- Point 3
- Bind invalidation to a security stamp on the account, not to each individual flow.
- Point 4
- No functional test covers this, because no real user produces the sequence.