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.Swipe the diagram to see the rest
What to include
- A clear button and the same link as plain text beneath it, for clients that strip buttons.
- How long the link works for.
- What to do if they didn't ask for a reset - usually nothing, and optionally a way to report it.
- Your product name in the From name and subject, so it is recognised at a glance.
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
Reset your Acme passwordYour Acme password reset linkAcme: reset link (expires in 30 minutes)
Send it
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.
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.