Recipe
Password reset
Same token machinery as magic-link, different purpose. User clicks the link, lands on a “set new password” form, the token is single-use and 30-minute-expiry.
Flow
- User clicks “forgot password” on your login page
- Your app accepts the email, ALWAYS returns 200 (don't leak account existence)
- If the email matches a user, you generate a reset token + send the email
- User clicks the link → lands on
/reset?t=... - Your app verifies the token, shows the “new password” form
- User submits → you update the password + invalidate the token + invalidate all existing sessions
Send
// app/api/auth/forgot-password/route.ts
import { sendTransactional } from "@/lib/sendbolt";
export async function POST(req: Request) {
const { email } = await req.json();
const user = await db.users.findByEmail(email);
if (user) {
const token = signResetToken({
userId: user.id,
email: user.email,
purpose: "password_reset",
exp: nowPlus(30 * 60),
});
const resetURL = `https://acme.com/reset?t=${token}`;
void sendTransactional({
to: user.email,
templateID: process.env.MP_TEMPLATE_PASSWORD_RESET!,
vars: {
FirstName: user.firstName ?? "there",
UserEmail: user.email,
ResetURL: resetURL,
ExpiryMinutes: "30",
IPAddress: getClientIP(req), // for "if this wasn't you" block
UserAgent: getUA(req),
},
});
}
// Always return the same response shape
return Response.json({ ok: true });
}Template — what to include
- The reset link as a clear button
- The expiry window — “This link works for 30 minutes”
- The requesting IP + rough geolocation — “Requested from a browser in Mumbai, India”
- An “if this wasn't you” line with a link to your security page (NOT to a different reset flow)
- Plain footer — no logo eye-candy, no marketing footer with unsub link (password reset is purely transactional)
Verify + consume
// app/reset/route.ts
import bcrypt from "bcryptjs";
export async function POST(req: Request) {
const { token, newPassword } = await req.json();
let claims;
try {
claims = verifyResetToken(token); // HMAC + expiry + purpose=password_reset
} catch {
return Response.json({ error: "Invalid or expired link" }, { status: 400 });
}
// Single-use enforcement
try {
await db.consumedTokens.insert({ nonce: claims.nonce });
} catch {
return Response.json({ error: "This link was already used" }, { status: 400 });
}
// Re-verify the user + email match (handles email-change race)
const user = await db.users.findById(claims.userId);
if (!user || user.email !== claims.email) {
return Response.json({ error: "Stale link — request a new one" }, { status: 400 });
}
// Enforce password policy
if (!isStrongEnough(newPassword)) {
return Response.json({ error: "Password too weak" }, { status: 400 });
}
await db.users.update(user.id, {
passwordHash: await bcrypt.hash(newPassword, 12),
passwordUpdatedAt: new Date(),
});
// CRITICAL: invalidate all existing sessions — the user might be resetting
// because someone else got into their account
await db.sessions.deleteWhere({ userId: user.id });
return Response.json({ ok: true });
}Don't do
- Don't leak account existence — return the same 200 whether the email matched a user or not
- Don't auto-login after reset — require a fresh login. Reset is a security event, treat it as one
- Don't reuse the magic-link token format for password reset — different
purposeclaim prevents cross-flow replay - Don't include the password in the email (some legacy systems do this — never)
Rate limits
Cap password-reset requests at 3 per hour per email address + 10 per hour per IP. Otherwise:
- Abuser spams a victim's inbox with reset emails — annoying + makes future legitimate resets look like more spam
- Abuser hammers your endpoint enumerating valid emails (timing attack)