Password Reset Email Fallback: A Node.js Guide to Code and Link Evidence
Short answer: for a gaming password reset, keep the link as the default and offer an email code when the player cannot open that link on the device where they started. Make both paths expire together, share an attempt bu
Short answer: for a gaming password reset, keep the link as the default and offer an email code when the player cannot open that link on the device where they started. Make both paths expire together, share an attempt budget, and consume one recovery grant atomically. Evaluate a managed OTP email API by the evidence it can export, not the speed of its first successful send. Neither an email handoff nor a code submission proves ownership of the gaming account on its own.
The constraint here is cross-device play. A link opened in a browser may leave a player waiting in the game client. A code can be entered there. It also gives an attacker a short secret to guess. I would benchmark integration time, but a quick first call is a poor bargain if the system cannot show when a grant was issued, attempted, consumed, and deleted without retaining the usable secret.
That evidence has to survive a retry.
Should a password reset email fallback use a code or a link?
Treat link and code as two presentations of one grant, not two independent opportunities to change a password. Record a random request ID, account pseudonym, issuance time, expiry, delivery handoff result, attempt count, and final outcome. Keep raw links, codes, and message bodies out of logs. This is an application design choice. A mail API accepting a message does not prove the recipient opened it. A player who requests a second message while the first is delayed creates a useful test case: decide whether the older grant is revoked or remains valid until expiry, then make that choice visible in the audit record. The delivery layer should not get to choose that behavior implicitly.
Use ten minutes and five failed attempts as example policy inputs, then review them against the actual threat model. An eight-character base64url code generated from six random bytes has 48 bits of randomness; a shorter numeric code needs a separate guessing analysis. Limit fresh issuance per account and network too. Otherwise a new request can reset the attempt budget. OWASP's forgot-password guidance covers single-use reset identifiers, expiry, rate limiting, and uniform responses that avoid account enumeration. Those requirements survive a change in delivery provider.
A minimal Node.js boundary
Keep verification in the application boundary. The store below is an interface, not a complete database implementation: its consume operation must lock or conditionally update a grant so concurrent attempts cannot both win. The mailer can be any transport that reports handoff success or failure.
import { createHash, randomBytes } from 'node:crypto';
type Grant = {
id: string; accountId: string; linkHash: string; codeHash: string;
expiresAt: number; attempts: number; consumed: boolean;
};
interface GrantStore {
save(grant: Grant): Promise<void>;
// Atomically check expiry, budget, both hashes, and single-use consumption.
tryConsume(id: string, accountId: string, hash: string, now: number): Promise<boolean>;
}
interface Mailer {
send(to: string, subject: string, body: string): Promise<void>;
}
const hash = (secret: string) => createHash('sha256').update(secret).digest('hex');
export async function issueReset(
accountId: string, email: string, store: GrantStore, mailer: Mailer,
): Promise<void> {
const id = randomBytes(16).toString('hex');
const linkSecret = randomBytes(32).toString('base64url');
const code = randomBytes(6).toString('base64url');
await store.save({ id, accountId, linkHash: hash(linkSecret),
codeHash: hash(code), expiresAt: Date.now() + 10 * 60_000,
attempts: 0, consumed: false });
const link = `https://accounts.example.test/reset?id=${id}&token=${linkSecret}`;
await mailer.send(email, 'Reset your game account password',
`Open ${link} or enter code ${code}. Both expire in 10 minutes.`);
}
export async function acceptReset(
id: string, accountId: string, supplied: string, store: GrantStore,
): Promise<boolean> {
return store.tryConsume(id, accountId, hash(supplied), Date.now());
}
The store must compare the candidate hash to both stored hashes, charge a failed attempt against the shared budget, reject attempts after expiry or five failures, and mark the grant consumed on a match. A conditional database update or transaction matters here. The interface alone does not make that happen. Bind accountId from server-side account lookup rather than trusting a public request parameter. Treat the code as case-sensitive, since base64url is case-sensitive; a form that changes case will silently break valid codes.
There is an awkward edge: saving a grant can succeed while mail delivery handoff fails. Mark the handoff failure against the request ID and invalidate the grant. Return the same public response used for an unknown address, without logging the secret to debug delivery. Keep link tokens away from analytics and referrer-bearing navigation.
Don't log the URL.
What I would change at scale
Put issuance and delivery in a durable outbox with a stable request ID. Ensure retries do not create another live grant by accident. Join grant events, delivery attempts, and handoff acknowledgments on that ID. Retain minimal evidence for the period the team's compliance policy requires, then delete it. Cheap storage is no reason to keep email addresses or reset material indefinitely. An expiry worker can clean up old rows, but expiry must also be checked in the consume transaction. A delayed worker must not extend a grant's life.
Test the state transitions with a frozen clock: submit at the expiry boundary, race a link against a code, race two workers on the final allowed attempt, and simulate a delivery timeout after persistence. Assert that no more than one reset succeeds and that the public response does not reveal whether an address exists. Inspect the audit record for reusable credentials. Measure delivery handoff latency separately from inbox arrival and completed recovery. Those are different events.
The trade-off to measure
Self-built verification offers direct control over expiry, redaction, and evidence fields. It also puts atomic storage, abuse limits, deployment, and retention on the team. A managed OTP email API may remove delivery glue, but check its event export, deletion controls, retry semantics, and support for one attempt budget across both presentations. Ask for a reproducible concurrent-consumption test. A settings screen is not evidence of the behavior under a race.
For this gaming flow, I would choose evidence completeness before convenience. Count the configuration knobs that need auditing, run the cross-device test, and compare the resulting event trail with the policy the team must defend. Message price and time-to-first-send cannot answer that question.
References
- OWASP, Forgot Password Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- Node.js, Crypto documentation: https://nodejs.org/api/crypto.html
- FTC, CAN-SPAM Act compliance guide for business: https://www.ftc.gov/business-guidance/resources/can-spam-act-compliance-guide-business
- Mustache template syntax manual: https://mustache.github.io/mustache.5.html
Further reading
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.