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.