Short answer: Diagnose an invalid password reset link by checking whether it was consumed, expired, or never matched the stored token digest. Do not extend its lifetime until you know which condition failed. For a logistics dispatch account, password recovery also needs an explicit decision about existing sessions; a reset link and a refresh token serve different purposes.
The data flow is small: email a random reset secret, store only its digest and deadline, display a form on GET, and atomically consume the secret with the password update on POST. After the update, apply a documented policy for active refresh-token families. Keep the browser's error generic while internal telemetry distinguishes expired, consumed, and unknown tokens.
Why does my password reset link say the token is invalid?
A mail client's link preview or a security scanner might open the URL before the dispatcher does. If opening the URL consumes the token, the person who requested it sees an invalid-link message. GET should only display a form. Consumption belongs to the deliberate password-change POST. The mere fact that a URL was opened is not evidence of account control. Imagine the driver coordinator tapping the link after an automated preview fetched the page: the issuance and delivery timestamps look normal, yet the consumed timestamp precedes the coordinator's first submission. Those three timestamps point to a GET side effect; changing the expiry would only hide the cause, and could leave a captured link useful for longer.
Check the timestamps first.
Two reset requests can also collide. Decide whether the second invalidates the first, implement that rule consistently, and record issued, expires, and consumed instants in UTC. Compare server-side instants, not the local clock shown in a browser at another depot. If consumption succeeds but saving the password fails, the implementation has split what should be one transaction. Fix the boundary.
OWASP recommends single-use, expiring reset tokens stored securely, along with responses that do not reveal account existence. A shorter window reduces exposure if a link leaks, but it also increases failures when email delivery is delayed. This is a real trade-off: a 20-minute policy may be unsuitable for a recovery channel whose delivery routinely takes longer. Select the deadline using observed delivery behavior; no universal duration is guaranteed by that guidance.
Implement the state transition
The following TypeScript defines the part that matters. The store is an application interface, not a vendor endpoint. Its conditional consume and password update must commit together; a success result means both happened. Generate the raw secret with a cryptographic random source and send it only through the recovery channel.
import { createHash, randomBytes } from 'node:crypto';
interface ResetStore {
save(digest: string, accountId: string, expiresAt: Date): Promise<void>;
consumeAndSetPassword(
digest: string, now: Date, passwordHash: string
): Promise<{ accountId: string } | null>;
}
const digest = (token: string) =>
createHash('sha256').update(token, 'utf8').digest('hex');
async function issueReset(store: ResetStore, accountId: string, now: Date) {
const token = randomBytes(32).toString('base64url');
const expiresAt = new Date(now.getTime() + 20 * 60 * 1000);
await store.save(digest(token), accountId, expiresAt);
return { token, expiresAt };
}
async function finishReset(
store: ResetStore, token: string, passwordHash: string, now: Date
) {
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
return store.consumeAndSetPassword(digest(token), now, passwordHash);
}
The 20-minute deadline is an example policy, not a standard. The store must require an unused record with expires_at > now inside the same transaction that saves the new password. Two simultaneous submissions should produce one successful commit. The passwordHash argument must be produced upstream by a suitable password hashing algorithm; SHA-256 here hashes the random reset token, not the password.
Keep the raw token out of logs, analytics, and third-party page requests. Set an appropriate referrer policy on the recovery page and remove the secret from the visible browser URL after capturing it. These details matter even when the database transaction is correct.
No secret in logs.
What happens to a stolen session after recovery?
Resetting a password proves control of the recovery channel, not that every open browser session belongs to its owner. A dispatcher account with access to shipment addresses needs an explicit post-reset session policy. Revoking existing refresh-token families after the password change limits continued renewal by a stolen session. Already-issued access tokens may still work until expiry unless resource servers consult revocation state. Document that exposure window rather than implying that a password change instantly cancels every credential.
Refresh-token rotation is a separate transition: each successful refresh replaces the old token. Reuse of an invalidated token can signal compromise, though concurrent client retries can resemble replay. Preserve family state for detection, revoke the affected family when reuse is detected, and serialize refresh attempts in the client. RFC 9700 describes rotation as a replay-detection mechanism for public clients. Never consume a password reset token merely because a page loaded. Different threat.
Migration off a managed provider adds an ownership question: which system validates links issued before cutover? Two independent token stores cannot assume each other's links are valid. One possible cutover routes outstanding links to their original issuer until expiry and sends newly issued links through the new store. However, dual routing adds operational complexity and is unsuitable when the old issuer cannot be kept available for the overlap period. In that case, schedule cutover after outstanding links expire and provide a fresh recovery path; the cost is a temporary interruption to users holding old links. Validate the chosen behavior before changing traffic. It is an architecture choice, not an automatic provider feature.
Verify the boundaries before cutover
Test a link twice, two concurrent POSTs, a modified token, and a token one instant before and exactly at expiry. Test a GET from a link scanner followed by a valid POST. Then request two links and check the chosen supersession rule. The visitor should receive a generic error while restricted telemetry records the reason; do not put email addresses or token values in metric labels. Measure delivery-to-first-open separately from issue-to-success so a mail delay is not mistaken for a validator defect.
Repeat the tests across old and new token owners. Exercise a stolen refresh token after rotation and after password reset, including a simultaneous retry. For a small team, this is also a cost and operational boundary: maintaining replay state and cutover routing takes engineering time even when the reset form itself is short. Move the flow only when reset consumption, password persistence, and session revocation each have a testable owner. Extending expiry cannot repair an accidental GET consumption or a split transaction.
Top comments (0)