Password reset links
A single-use link that expires, and a form that does not reveal who has an account.
A reset link is a temporary password delivered by email. Everything about it follows from that: it has to be unguessable, short-lived, usable once, and stored the way you would store a password.
The request handler
import crypto from "node:crypto"
export async function requestReset(rawEmail) {
const email = rawEmail.trim().toLowerCase()
const user = await db.users.findByEmail(email)
if (user) {
// 32 random bytes: far beyond guessing, unlike a six-digit code.
const token = crypto.randomBytes(32).toString("base64url")
const tokenHash = crypto.createHash("sha256").update(token).digest("hex")
// Only the hash is stored. A copy of this table is then useless to
// whoever has it -- the link needs the token, not its hash.
await db.passwordResets.upsert({
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
})
const link = "https://app.acme.com/reset?token=" + token
await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ROVELA_API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": "reset-" + tokenHash.slice(0, 32),
},
body: JSON.stringify({
from: "Acme <security@mail.acme.com>",
to: email,
subject: "Reset your Acme password",
html:
"<p>Someone asked to reset the password for this address.</p>" +
'<p><a href="' + link + '">Choose a new password</a></p>' +
"<p>The link works once and expires in 30 minutes. " +
"If it was not you, ignore this email -- your password has not changed.</p>",
text:
"Someone asked to reset the password for this address.\n\n" +
"Choose a new password: " + link + "\n\n" +
"The link works once and expires in 30 minutes.",
}),
})
}
// The same answer whether or not the account exists.
return { message: "If that address has an account, a reset link is on its way." }
}The response must not depend on whether the address is registered — not in the text, not in the status code, and ideally not in timing either. Otherwise the reset form is a free tool for checking which addresses are your customers. Sending in a background job removes the timing difference as well.
Using the token
export async function resetPassword(token, newPassword) {
const tokenHash = crypto.createHash("sha256").update(token).digest("hex")
// Delete and return in one step: the token is spent even if two requests
// arrive with it at the same moment.
const row = await db.query(
`DELETE FROM password_resets
WHERE token_hash = $1 AND expires_at > NOW()
RETURNING user_id`,
[tokenHash]
)
if (row.rowCount === 0) return false
await db.users.setPassword(row.rows[0].user_id, newPassword)
await db.sessions.revokeAllFor(row.rows[0].user_id) // log out everywhere
return true
}A plain SHA-256 is enough here, unlike for six-digit codes: 32 random bytes cannot be brute-forced from their hash, so there is nothing for a secret key to protect.
Mail-specific details
- Click tracking rewrites links. If tracking is on for the sending domain, the reset URL is wrapped in a redirect through our tracking host. It still works, but consider a separate domain with tracking off for security mail — the token should go straight to you.
- Always include
text. Some corporate mail filters strip links from HTML; the plain-text part with the full URL is the fallback that still works. - Tell them after, too. Send a short "your password was changed" message once the reset succeeds. It is the only way the real owner finds out if someone else got in.