Dunicot A cybersecurity consultancy and advisory firm.

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, where each cell is a test case
ArtefactTransition performed after issuingExpected on consume
Password reset tokenEmail address changedRejected
Password reset tokenPassword changed by another routeRejected
Email verification linkEmail address changed againRejected
Session cookie / JWTPassword changedRejected
Session cookie / JWTMFA enrolled or resetRe-authentication required
Refresh tokenLogout, or “sign out everywhere”Rejected
API key / PATOwner removed from organisationRejected
Team invitation linkInvitation revoked, or role downgradedRejected
OAuth authorisation codeAccount password changedRejected
Magic sign-in linkA newer link requestedOlder 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.

Want this applied to your stack?

Everything written here comes out of delivered engagements. Describe the platform and the deadline.