Password reset is one of the highest-privilege operations in your auth system. A successful reset lets an actor fully control an account without knowing the current password. Every weakness in the reset flow is a potential account takeover path. Despite this, reset flows are frequently implemented with shortcuts that introduce real vulnerabilities — predictable tokens, missing expiry enforcement, or responses that confirm whether an email has an account.
Token generation: what "random enough" actually means
The reset token must be cryptographically random and have enough entropy that it cannot be guessed or brute-forced within its validity window. A token with 32 bytes of entropy (256 bits) from a CSPRNG is appropriate. Anything derived from predictable inputs (timestamp, user ID, sequential counter) is not acceptable.
import crypto from 'crypto';
import { createHash } from 'crypto';
async function issuePasswordResetToken(userId) {
// Generate 32 bytes of cryptographic randomness
const rawToken = crypto.randomBytes(32).toString('hex'); // 64 hex chars
// Store only the hash in the DB — token itself is a credential
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
await db.passwordResetTokens.create({
userId,
tokenHash,
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
usedAt: null,
});
// The raw token is what goes in the email link
return rawToken;
}
// The reset URL in the email:
// https://app.example.com/reset-password?token=
Hashing the token before database storage follows the same principle as hashing passwords. If your database is breached, the attacker gets hashed tokens, not the raw values. The window to use the raw token is already short (1 hour), but defense in depth applies.
Signed tokens vs database tokens
An alternative to database-stored tokens is a signed JWT carrying the reset intent. The server signs it with an HMAC secret, and verification requires only the signature check — no database read needed.
import { SignJWT, jwtVerify } from 'jose';
const RESET_SECRET = new TextEncoder().encode(process.env.RESET_TOKEN_SECRET);
async function issueSignedResetToken(userId, currentPasswordHash) {
// Including a fingerprint of the current password hash means the token
// is automatically invalidated if the password changes (including from
// another reset that already completed)
const fingerprint = createHash('sha256')
.update(currentPasswordHash)
.digest('hex')
.slice(0, 16);
return new SignJWT({ sub: userId, fp: fingerprint })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(RESET_SECRET);
}
async function consumeSignedResetToken(token, newPassword) {
const { payload } = await jwtVerify(token, RESET_SECRET);
const user = await db.users.findById(payload.sub);
const expectedFingerprint = createHash('sha256')
.update(user.passwordHash)
.digest('hex')
.slice(0, 16);
// Token fingerprint must match current password hash fingerprint
// This invalidates the token if the password was already changed
if (payload.fp !== expectedFingerprint) {
throw new Error('reset_token_already_used');
}
await db.users.updatePassword(user.id, await hashPassword(newPassword));
}
The signed token approach is stateless but has a nuance: without a database record, it is harder to enforce single-use. The password fingerprint trick above solves this — once the password changes, the token's fingerprint no longer matches. For database-stored tokens, mark them used atomically with the password update.
Single-use enforcement
// Atomic: verify token and update password in one transaction
async function consumeDatabaseResetToken(rawToken, newPassword) {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
await db.transaction(async (tx) => {
const record = await tx.passwordResetTokens.findOne({
tokenHash,
usedAt: null,
});
if (!record) throw new Error('token_not_found_or_already_used');
if (new Date() > record.expiresAt) throw new Error('token_expired');
// Mark used before changing password — prevents race condition
await tx.passwordResetTokens.update(
{ id: record.id },
{ usedAt: new Date() }
);
const newHash = await hashPassword(newPassword);
await tx.users.update({ id: record.userId }, { passwordHash: newHash });
// Invalidate all active sessions for this user
await tx.sessions.deleteMany({ userId: record.userId });
});
}
Account enumeration prevention
The "Forgot password" form must return the same response whether or not the submitted email address has an account. If you return "Email not found" for unregistered addresses, you confirm account existence to anyone who submits addresses. If you return "Reset link sent" for registered addresses, you leak that the email is in your system.
The correct response is always: "If an account with that email exists, a reset link has been sent." No exception. On the backend, you still check whether the email exists and only send the email if it does — but the HTTP response and UI copy are identical in both cases.
app.post('/auth/forgot-password', async (req, res) => {
const { email } = req.body;
// Always respond the same way — do not reveal account existence
const genericResponse = {
message: 'If an account with that email exists, a reset link has been sent.',
};
const user = await db.users.findByEmail(email.toLowerCase());
if (user) {
const token = await issuePasswordResetToken(user.id);
await sendResetEmail(user.email, token);
// Log the event for monitoring
logger.info({ event: 'password_reset_requested', userId: user.id });
}
// Even if user is null, we still respond with the same 200 response
// Add a small fixed delay to prevent timing-based enumeration
await sleep(200);
res.json(genericResponse);
});
Expiry notification and token reuse UX
When a user arrives at a reset link that has expired (either because an hour passed or because they already used it), the error message matters for UX. "This link has expired" is better than "Invalid token" — it tells the user what happened and implies they should request a new one. "This link has already been used" is informative if the user completed the reset successfully in another tab and arrived at the link again from email.
One subtle issue: email security scanners (corporate proxies, antivirus gateways) sometimes follow links in emails to check for malicious content. This can silently trigger a GET to your reset URL. If your reset flow completes on GET (which it should not — GET should only show the form, POST should process the reset), the scanner will consume the token before the user sees the email. Always require a form submission step between landing on the reset URL and actually changing the password.