password reset email

How to send password reset emails from a REST API

How to send a secure password reset email: token generation, expiry, what the email should say, deliverability, and complete API code.

In short

A password reset email carries a single-use link with a long random token that expires in 15 to 60 minutes. Store only a hash of the token, send the email from your verified domain through a transactional email API, and turn click tracking off - rewriting the link through a tracking domain looks like phishing and breaks when scanners pre-fetch it. Always show the same "check your inbox" message whether or not the account exists.
A secure password reset flow over a REST APIUserYour APISendpositoryInbox + scannerPOST /password/forgot{ email }202 "check your inbox"same reply either waytoken = 32 random bytesstore sha256(token), 30 minPOST /v1/emailstrack_clicks: falsedeliverscanner GETs the linkform only, changes nothingPOST /password/reset{ token, password }verify hash + expirymark used, revoke sessionssend "password changed"

Swipe the diagram to see the rest

A password reset that is safe against account enumeration and link scanners. The link only shows a form; only the form submission changes anything.

What to include

Best practices

Long random tokens

At least 32 bytes from a CSPRNG, base64url-encoded. Store a hash, and mark it used the moment the password changes.

Don't reveal accounts

Respond identically whether the address is registered or not. Only send the email when it is.

Click tracking off

Security scanners open links on delivery. With tracking off the link is exactly your URL, so a scanner visit can't consume a one-time token via a redirect.

Make the link GET-safe

Opening the link should show a form, not change anything. Scanners follow links; only the form submission should reset the password.

Tell them afterwards

Send a short "your password was changed" email once the reset completes, so an account takeover doesn't go unnoticed.

Subject lines that work

Send it

Node.js
import { randomBytes, createHash } from "node:crypto";

const token = randomBytes(32).toString("base64url");
await db.passwordResets.create({
  userId: user.id,
  hash: createHash("sha256").update(token).digest("hex"),
  expiresAt: new Date(Date.now() + 30 * 60_000),
});

const link = `https://yourapp.com/reset?token=${token}`;

await fetch("https://api.sendpository.com/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SENDPOSITORY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "Acme <security@mail.yourdomain.com>",
    to: [user.email],
    subject: "Reset your Acme password",
    html: `<p>Someone asked to reset your password.</p>
<p><a href="${link}">Choose a new password</a> - this link works for 30 minutes.</p>
<p>If that wasn't you, ignore this email. Your password won't change.</p>`,
    text: `Reset your password: ${link}\nThis link works for 30 minutes.`,
    track_clicks: false,
    tags: { type: "password_reset" },
  }),
});

Using another language? The same request works from any stack - it is JSON over HTTPS.

The three endpoints

A reset needs three routes: one to request a link, one the link opens, and one that sets the new password. Only the last one changes anything - which is what makes the flow safe when an email scanner opens the link before the user does.

password-reset.ts (Express)
import crypto from "node:crypto";
import express from "express";
import { Sendpository } from "sendpository";

const app = express().use(express.json());
const sendpository = new Sendpository(process.env.SENDPOSITORY_API_KEY);
const sha256 = (s: string) => crypto.createHash("sha256").update(s).digest("hex");

// 1. Request a link. The reply is identical whether or not the account exists.
app.post("/password/forgot", async (req, res) => {
  res.status(202).json({ message: "If that address has an account, a link is on its way." });

  const user = await db.user.findByEmail(String(req.body.email ?? ""));
  if (!user) return;

  const token = crypto.randomBytes(32).toString("base64url");
  const reset = await db.passwordReset.insert({
    userId: user.id,
    tokenHash: sha256(token),
    expiresAt: Date.now() + 30 * 60_000,
  });
  const link = `https://app.acme.com/reset?token=${token}`;

  await sendpository.emails.send(
    {
      from: "Acme <security@mail.acme.com>",
      to: [user.email],
      subject: "Reset your Acme password",
      html: `<p><a href="${link}">Choose a new password</a>. The link works for 30 minutes.</p>
<p>If you didn't ask for this, ignore this email.</p>`,
      text: `Choose a new password: ${link}\nThe link works for 30 minutes.`,
      track_clicks: false, // the link must be exactly yours
      tags: { type: "password_reset" },
    },
    { idempotencyKey: `reset-${reset.id}` },
  );
});

// 2. The link opens a form. GET never changes state - scanners follow links.
app.get("/reset", (req, res) => res.render("reset-form", { token: req.query.token }));

// 3. Setting the password is the only step that acts.
app.post("/password/reset", async (req, res) => {
  const reset = await db.passwordReset.findByHash(sha256(String(req.body.token ?? "")));
  if (!reset || reset.usedAt || reset.expiresAt < Date.now()) {
    return res.status(400).json({ error: "link_expired" });
  }
  await db.transaction(async (tx) => {
    await tx.user.setPassword(reset.userId, await hashPassword(req.body.password));
    await tx.passwordReset.markUsed(reset.id);
    await tx.session.revokeAll(reset.userId); // sign out every other device
  });

  const user = await db.user.find(reset.userId);
  await sendpository.emails.send({
    from: "Acme <security@mail.acme.com>",
    to: [user.email],
    subject: "Your Acme password was changed",
    text: "Your password was just changed. If this wasn't you, reply to this email.",
    reply_to: ["security@acme.com"],
  });
  res.json({ ok: true });
});

Questions

How long should a password reset link last?

15 to 60 minutes. Shorter is safer; longer tolerates slow delivery. Whatever you choose, make the token single-use.

Why does my reset link say it has expired when it hasn't been clicked?

An email security scanner probably opened it first. Make the link show a form rather than acting on GET, and don't route it through click tracking.

Should password reset emails be tracked?

No. Opens and clicks add nothing a delivery log doesn't already tell you, and rewritten links hurt both security and deliverability.

Related

Send your first email in minutes

6,000 emails a month (up to 200 a day), free forever. No card needed.