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

  1. User clicks “forgot password” on your login page
  2. Your app accepts the email, ALWAYS returns 200 (don't leak account existence)
  3. If the email matches a user, you generate a reset token + send the email
  4. User clicks the link → lands on /reset?t=...
  5. Your app verifies the token, shows the “new password” form
  6. 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 purpose claim 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)