Backend

Make a password reset token work only once under concurrency

Kamruz
Kamruz
Oct 14, 2026
5 min read
Make a password reset token work only once under concurrency

Two requests submit the same password-reset link almost simultaneously. Each reads an unexpired token, each changes the password, and each deletes the token afterward. The final password depends on which write finishes last. The token was described as single use, but the implementation enforced that description only after the sensitive action had already happened.

This example assumes a Node service and MongoDB. It stores the reset grant on the account document so token consumption and password replacement can be one atomic update. A separate token collection is possible, but then the related writes may require a transaction and a deliberate recovery policy.

Define the reset invariant

A valid grant identifies one account, expires after a bounded interval, and authorizes one credential replacement. The server should not accept an account identifier supplied separately by the browser as the authority. The token itself maps to the intended account through trusted storage.

Store a digest of a cryptographically random high-entropy token rather than the raw bearer token. The raw value goes to the user's reset link; the digest is used for lookup. Do not confuse token hashing with password hashing. Passwords need a password-hashing algorithm and appropriate parameters, while a random token has different entropy and lookup requirements.

Define how issuing a new grant affects an old one. Replacing the account's stored grant can invalidate earlier links, which is often useful. Rate-limit issuance and redemption independently, and keep account-enumeration behavior deliberate. These surrounding controls matter, but they do not repair the concurrent consumption race by themselves.

Find the vulnerable sequence

Trace the handler from token lookup to password update to cleanup. Look for separate reads and saves that depend on an earlier token check. An ORM document loaded before another request consumes the token can still carry stale state and overwrite a later credential change.

Reproduce with a barrier so two requests both pass the old lookup before either writes. Use synthetic accounts and distinct proposed passwords. The test should demonstrate two successful redemptions before the fix and exactly one afterward. Do not rely on a manual double click that may be serialized by the browser.

Ask an AI reviewer to identify the precise atomic boundary. A response that recommends deleting the token faster misses the problem: any gap between validation and the credential write can still admit another request.

Prepare expensive work before the atomic write

Validate the new password and compute its secure hash before the final conditional update. The update filter must still verify the token digest and expiry at write time. Doing expensive hashing earlier does not authorize the operation; it only avoids holding a transaction or critical write path open while hashing.

Use the installed password library's documented interface and parameters. Do not paste a token or password into an AI prompt to debug this path. Synthetic inputs and a safe hash-library configuration provide sufficient evidence.

Capture the server's current time for the conditional expiry check according to the application's time policy. If a token expires during hashing, the final write should reject it. A TTL index is cleanup, not authorization: expired records may remain until background deletion, so the expiry predicate must be part of redemption.

Consume and replace atomically

With grant fields on the account document, an update can require the matching digest and a future expiry, set the new password hash, clear the grant, and advance a session version. Only one concurrent update should match after the grant is cleared.

const result = await accounts.updateOne(
{ resetDigest: digest, resetExpiresAt: { $gt: new Date() } },
{
$set: { passwordHash: preparedHash },
$unset: { resetDigest: '', resetExpiresAt: '' },
$inc: { sessionVersion: 1 }
}
)
if (result.matchedCount !== 1) throw new InvalidResetGrant()

This outline assumes a unique reset digest when present, correct account scoping in the actual schema, and a session system that enforces the version. The version increment alone does not revoke sessions unless every relevant session check observes it. The error class is application code.

If token and account live in separate documents, consuming the token and changing the password need a shared transaction when the invariant requires both or neither. Do not consume the token first and then risk leaving the user unable to retry after a failed password write.

Handle the response without weakening the rule

Return a safe invalid-or-expired message when the conditional write does not match. Do not reveal internal token state or account details. After a successful reset, the response can guide the user to sign in again rather than automatically creating a broad session. Follow the product's security policy for that choice.

Send any notification after the durable credential update through a recoverable delivery path. A notification failure should not undo or repeat the password change. Avoid logging the full reset URL because it contains a bearer credential.

Session revocation is a separate observable requirement. If cookie sessions, API tokens, and remembered devices use different storage, advancing one field may cover only some of them. Record which sessions are revoked and test each relevant authentication path.

Verify concurrency and failure windows

Run two simultaneous redemptions against isolated real MongoDB storage. Assert one success, one invalid-grant outcome, one credential replacement, and no usable reset grant afterward. Test expired, malformed, already consumed, and superseded grants.

Inject a password-hashing failure before the write and confirm the valid grant remains usable. Inject a storage failure and verify no success response is returned. If using a transaction, test a failure between token and account writes and assert rollback preserves the invariant.

Test existing sessions after success. Verify the session-version or revocation mechanism through actual authentication checks rather than simply inspecting the incremented field. A database assertion proves a field changed; it does not prove a previously issued token stops working.

Keep the design narrow

A single-document grant is simple when the account model supports it. A separate grant collection offers history or multiple controlled grants but requires more lifecycle decisions. Choose from the needed reset behavior, not from an assistant's generic authentication template.

Concurrent uniqueness and transaction boundaries explain the storage mechanisms behind the repair. Safe API auditing explains how to review the path without exposing credentials. Single use becomes credible when the write itself enforces it under contention.

Protect the account state in the write

Add account eligibility to the conditional write when policy requires it. For example, a deleted or disabled account should not regain access merely because an earlier reset grant remains valid. Do not check that state only in a preliminary read; include the relevant active-state predicate at redemption. Test disabling the account between token issuance and submission. If the password hash update succeeds but notification fails, preserve the consumed grant and report delivery separately. Reissuing the same grant to compensate would reopen the credential operation rather than repair the notification.

Sources

About the Author

Kamruz

Kamruz

Full Stack Developer

A passionate developer who loves creating modern web applications and sharing knowledge with the community.

More About Me

Related Articles