Password Reset Without a Backdoor
The flow that quietly becomes the weakest way into every account, and the seven rules that stop it.
Password reset is a way to take over an account without knowing the password. That is its purpose — which is why it deserves the same scrutiny as the login it bypasses.
The seven rules
- Store a hash of the reset token, never the token itself. It is a credential.
- Single use. Consume it atomically, in the same statement that validates it.
- Short expiry — 15 to 60 minutes.
- Never reveal whether the email exists. Same response, same status, similar timing, whether or not there is an account.
- Invalidate every session on success. Otherwise the attacker who prompted the reset keeps their existing session.
- Also invalidate outstanding reset tokens when the password changes by any route.
- Notify the user by email that the password changed, with a "this wasn't me" link.
The token
At least 128 bits from a CSPRNG. Not a UUIDv1 (time-based, partly predictable), not a hash of the email, not an incrementing id.
Rate limit both ends
Limit requests per email and per IP, or your reset form is an email bomber pointed at your own domain reputation. Limit verification attempts too.
The subtle ones
- Host header injection. If you build the reset URL from
req.headers.host, an attacker can send a request with their own host and receive a link pointing at their server — with the victim's token in it. Build the URL from configuration. - Referer leakage. A token in a URL is sent in the
Refererof any external resource on the landing page. Consume the token immediately and redirect to a clean URL, and setReferrer-Policy: no-referrer. - MFA. If the account has a second factor, a password reset must not skip it — or the second factor is optional in practice.
Example
// Always the same answer, whether or not the account exists
app.post('/auth/forgot', resetLimiter, async (req, res) => {
const email = String(req.body.email ?? '').toLowerCase().trim();
const user = await db.users.findByEmail(email);
if (user) {
const token = randomBytes(32).toString('base64url');
await db.resetTokens.insert({
hash: sha256(token),
userId: user.id,
expiresAt: new Date(Date.now() + 30 * 60e3),
});
// URL from CONFIG, never from req.headers.host
await sendResetEmail(email, `${process.env.APP_URL}/reset?token=${token}`);
}
res.json({ message: 'If that address is registered, we have sent a link.' });
});When to use it
- An attacker who gained temporary access is evicted when the victim resets their password, because the reset destroys every session.
- A reset link expires after 30 minutes and cannot be reused, so a forwarded email does not hand over the account.
- A security test confirms the forgot-password endpoint gives identical responses for registered and unregistered addresses.
More examples
The reset endpoint, done fully
The transaction is the important part: password change, token cleanup and session revocation either all happen or none do. A partial reset leaves live sessions behind.
import { randomBytes, createHash } from 'crypto';
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
app.post('/auth/reset', resetLimiter, async (req, res) => {
const { token, password } = req.body ?? {};
if (!token || !password) return res.status(400).json({ error: 'invalid_request' });
await validatePassword(password); // length + breach check
// Validate AND consume in one statement — no check-then-use race.
const row = await db.resetTokens.consumeIfValid({ hash: sha256(token), now: new Date() });
if (!row) {
return res.status(400).json({ error: 'invalid_or_expired_token' });
}
const user = await db.users.findById(row.userId);
// If the account has MFA, the reset must not bypass it.
if (user.totpEnabled && !(await verifyTotp(user.id, req.body.mfaCode))) {
return res.status(401).json({ error: 'mfa_required' });
}
await db.transaction(async (tx) => {
await tx.users.update(user.id, {
passwordHash: await argon2.hash(password, ARGON2_OPTIONS),
passwordChangedAt: new Date(),
});
await tx.resetTokens.deleteAllFor(user.id); // any other outstanding links
await tx.sessions.deleteAllFor(user.id); // every device, everywhere
await tx.refreshTokens.revokeAllFor(user.id); // and every token family
await tx.users.increment(user.id, 'tokenVersion'); // kill live access tokens
});
// Tell the user. A reset they did not request is the signal they need.
await sendMail(user.email, 'Your password was changed', {
when: new Date(), ip: req.ip,
notYouUrl: `${process.env.APP_URL}/security/report`,
});
// Sign them in on THIS device only.
res.cookie('sid', await createSession(user.id, req), COOKIE_OPTIONS);
res.status(204).end();
});
// SQL behind consumeIfValid:
// UPDATE reset_tokens SET used_at = now()
// WHERE hash = $1 AND used_at IS NULL AND expires_at > now()
// RETURNING user_id;Host header injection, and the fix
The same rule applies to every absolute URL you generate — email links, OAuth redirect URIs, webhook callbacks. Configuration, never a request header.
// ❌ The attacker controls this header
app.post('/auth/forgot', async (req, res) => {
const url = `https://${req.headers.host}/reset?token=${token}`;
await sendResetEmail(email, url);
});
// The attack:
// POST /auth/forgot
// Host: evil.com
// { "email": "[email protected]" }
//
// The victim receives a genuine email from you, containing:
// https://evil.com/reset?token=REAL_VALID_TOKEN
// They click it, and the attacker's server captures a working token.
// ✅ Build the URL from configuration only
const APP_URL = process.env.APP_URL; // https://abc.com — set at deploy
if (!APP_URL) throw new Error('APP_URL must be configured');
const url = `${APP_URL}/reset?token=${encodeURIComponent(token)}`;
// ✅ Belt and braces: reject unexpected Host headers at the edge
app.use((req, res, next) => {
const allowed = new Set(['abc.com', 'www.abc.com']);
if (!allowed.has(req.hostname)) return res.status(400).send('Bad Host');
next();
});
// ✅ And stop the token leaking through Referer once it is in a URL
app.use((req, res, next) => {
res.set('Referrer-Policy', 'no-referrer');
next();
});
Discussion