For a marketplace, renew a login only after recovery has proved control of the account, and revoke the old session family as one auditable operation. The decision rule is: use a single-use verification token, rotate refresh credentials after the password change, and attach a correlation ID to every allow or deny decision. That order limits bot persistence without teaching an attacker which accounts exist.
Short answer: keep verification, refresh, and revocation as separate boundaries with separate events. A reset request should reveal no account state. A consumed token should never be accepted again. A password update should invalidate every older session family before a new session is issued.
Pick a boundary that matches your abuse model
| Pattern | Pick this when | Trade-off to accept |
|---|---|---|
| Opaque token and server-side session | Immediate revocation and clear audit evidence matter most | Each verification needs a durable lookup |
| Signed token plus a family store | Multiple services need to read token claims locally | One-time use still requires shared state |
| Recovery with an enrolled second factor | Buyers and sellers already protect accounts with another factor | Factor enrollment and fallback become new abuse targets |
This table is a selection tool, not a product ranking. A signature proves that a service minted a value; it does not prove that the value has not been replayed. Likewise, a second factor helps only if enrollment and recovery receive the same rate limits as login.
For the marketplace flow, I would start with an opaque random value. Store a hash, expiry, consumed timestamp, account identifier, and the risk decision. Never place the raw value in logs, analytics URLs, or support tickets. The email link can be copied, but the database should hold only the digest.
What should verification, refresh, and revocation boundaries do?
Picture a one-way diagram: request reset -> verify token -> set password -> rotate session family -> revoke old family -> emit audit event. A request moves right once. A timeout or retry must not create a second path through the diagram.
Audit trails matter.
Verification owns proof. Return the same generic body for an unknown email and a known email, then apply limits by account, source address, device signal, and velocity. OWASP calls for generic authentication responses and defenses against automated attacks; password recovery needs the same treatment. A verification failure should produce a stable event name, not a verbose reason that becomes an account-enumeration oracle.
Refresh owns continuity. Keep access tokens short-lived. Rotate the refresh token on every successful use and store its family state server-side. If an old member of that family appears after rotation, classify it as reuse, revoke the family, and require recovery again. That is a security event, not a harmless mobile retry.
Revocation owns blast radius. A password change, primary-email change, or high-confidence takeover signal should revoke every active family for the account. Persist the timestamp and reason in the audit record. A five-minute access-token lifetime still leaves a window, so a resource server that needs immediate cutoff must consult session state or an equivalent revocation signal.
Three words: fail closed.
How can a TypeScript pipeline make recovery observable without leaking identity?
Use stable event names and a correlation ID, while keeping secrets and raw addresses out of fields and messages. The following plain TypeScript separates policy from the HTTP framework. Its first function always returns the same public response.
type VerifyResult = "accepted" | "throttled" | "invalid" | "replayed";
type ResetRecord = {
tokenHash: string;
accountId: string;
expiresAt: number;
consumedAt?: number;
};
interface RecoveryStore {
findByHash(hash: string): Promise<ResetRecord | undefined>;
consume(hash: string, now: number): Promise<boolean>;
setPassword(accountId: string, passwordHash: string): Promise<void>;
revokeFamilies(accountId: string, reason: string): Promise<void>;
}
const genericResponse = {
message: "If the account can be recovered, instructions will arrive shortly.",
};
export async function requestReset(
email: string,
signals: { ip: string; device: string },
): Promise<typeof genericResponse> {
const allowed = await limiter.allow({ key: `${email}:${signals.ip}`, cost: 1 });
audit(allowed ? "recovery.request.accepted" : "recovery.request.throttled", {
ip: signals.ip,
device: signals.device,
});
return genericResponse;
}
export async function finishReset(
rawToken: string,
newPasswordHash: string,
store: RecoveryStore,
): Promise<VerifyResult> {
const now = Date.now();
const tokenHash = sha256(rawToken);
const record = await store.findByHash(tokenHash);
if (!record || record.expiresAt <= now) {
audit("recovery.verify.invalid", {});
return "invalid";
}
const consumed = await store.consume(tokenHash, now);
if (!consumed) {
audit("recovery.verify.replayed", { accountId: record.accountId });
await store.revokeFamilies(record.accountId, "reset-token-replay");
return "replayed";
}
await store.setPassword(record.accountId, newPasswordHash);
await store.revokeFamilies(record.accountId, "password-reset");
audit("recovery.completed", { accountId: record.accountId });
return "accepted";
}
The ordering is deliberate: consume, update, revoke, then issue a new session. Put these writes in one transaction or an equivalent idempotent workflow. If a client retries after a network timeout, the consumed marker prevents a second password update from masquerading as a new verification. Your mileage may vary when queues or replicas add delay, so record the correlation ID across those hops and test the longest expected clock skew.
I instrument counters for recovery.verify.invalid, recovery.verify.replayed, refresh.reuse, and session.revoked, plus latency histograms split by outcome. RFC 9700 describes refresh-token replay as a reason to invalidate the active token relationship, which is a useful policy anchor when documenting this event. A spike in 429 responses can indicate an attack, but it can also indicate a broken mobile retry loop. Pair metrics with sampled traces that contain only the correlation ID. Never sample the reset token.
Where do common implementations fail in production?
The first failure is an enumeration leak: a different status code, response length, or email timing for an unknown address. Normalize the public response and move account-specific detail to internal events. The second is a race between two submissions. An atomic consume operation, guarded by a unique token hash, makes one winner explicit. In a busy marketplace, imagine two browser tabs and a phone all submitting the same link within a few seconds: a read-then-write check can let each request observe "unused," while a single conditional update gives exactly one request the consumed marker. The losing requests should emit the same public failure, preserve the same correlation ID where possible, and never retry the password write. This is the kind of edge case that disappears in a happy-path demo but shows up in an audit replay.
The third failure is revoking too late. If the password write commits while old refresh tokens remain valid, an attacker with a stolen token can continue. Treat password update and family revocation as one state transition, and make session issuance a later step.
The fourth is an observability leak. Logs that include a full URL can retain the reset value in a proxy, browser history export, or support dashboard. Redact query strings and headers at the edge, and log only a token fingerprint when an investigation needs correlation.
Three real services illustrate why boundaries differ. Auth0 documents refresh-token rotation and reuse detection as configurable behavior; that convenience still depends on your application deciding when to revoke a family. Amazon Cognito exposes refresh-token validity as a pool setting, which is useful for policy but does not replace an audit event for a password reset. Firebase Authentication offers password reset emails and client SDK flows, while teams still own abuse throttling and marketplace-specific evidence. These are different operating choices, not a leaderboard.
Limits and a practical decision rule
The catch is friction. Aggressive limits can block a legitimate seller behind a shared office NAT, while loose limits give a bot room to enumerate recovery traffic. Progressive challenges and a human escalation path are safer than returning “email not found.”
This design is not suitable when the team cannot operate an encrypted token store, a durable audit trail, or a revocation check on protected resources. Stick with a managed identity service when its documented recovery hooks meet those controls and your marketplace does not need custom risk policy at each boundary.
I am not sure one timeout fits every marketplace. Test expiry, clock skew, queue delay, replica lag, and refresh reuse in staging; then document the chosen values as policy. The flow is ready for an audit when one correlation ID can explain the request, verification result, password update, revocation reason, and newly issued session without exposing a secret or confirming that an account exists.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://www.rfc-editor.org/rfc/rfc6819
- https://www.rfc-editor.org/rfc/rfc9700
- https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle
- https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation
- https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-refresh-token.html
- https://firebase.google.com/docs/auth/web/manage-users
Top comments (0)