Password reset email is one of those features that looks tiny on a roadmap and then eats a week when you do it carefully. The email provider is only one piece. You also need a safe token lifecycle, an endpoint that does not leak whether an account exists, rate limits, HTML that still works in locked-down clients, and a way to prove the message was accepted for delivery.
This guide walks through a production-shaped backend flow and shows how to send the message with Notify, a transactional email API aimed at developers who want one HTTP call plus logs and webhooks.
What you are actually building
A complete reset path has six moving parts:
- Request endpoint —
POST /auth/forgot-passwordaccepts an email. - Token minting — cryptographically random, short-lived, single-use.
- Storage — store a hash of the token, never the raw value.
- Email send — server-side call to a transactional email API.
- Consume endpoint —
POST /auth/reset-passwordvalidates token + new password. - Invalidation — mark the token used (or delete it) and optionally revoke sessions.
If any of those are weak, the email API cannot save you.
Security rules that should be non-negotiable
Always return a generic response
Whether the email exists or not, respond with the same message:
If an account exists for that email, we sent a reset link.
That prevents account enumeration. Log internally when the user was missing; do not tell the client.
Hash tokens at rest
Generate at least 32 random bytes (64 hex characters is fine). Store sha256(token) or a slow hash. Email the raw token inside the link. When the user returns, hash what they present and compare.
Short TTL + single use
Fifteen to sixty minutes is typical. After a successful reset, invalidate the token immediately. Prefer invalidating older outstanding reset tokens for that user when a new one is created.
Rate limit by IP and by email
Attackers will spray forgot-password. Cap requests per IP and per target email (for example 5 per hour per email). Add CAPTCHA on the public form if you are already seeing abuse.
Send only from your backend
Put the email API key in server environment variables. Never call Notify (or any provider) from the browser.
Data model sketch
create table password_reset_tokens (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
token_hash text not null unique,
expires_at timestamptz not null,
used_at timestamptz,
created_at timestamptz not null default now()
);
create index on password_reset_tokens (user_id);
create index on password_reset_tokens (expires_at);
Optional but useful columns: request_ip, user_agent, email_message_id (from the provider response) so support can correlate “I never got the email” with a delivery log.
Step 1 — forgot-password handler
import crypto from "node:crypto";
import { createHash } from "node:crypto";
type ForgotBody = { email?: string };
export async function forgotPassword(req: Request) {
const body = (await req.json()) as ForgotBody;
const email = (body.email || "").trim().toLowerCase();
// Always the same outward response
const ok = Response.json({
message: "If an account exists for that email, we sent a reset link.",
});
if (!email.includes("@")) return ok;
const user = await db.users.findByEmail(email);
if (!user) {
await audit.log({ type: "password_reset_unknown_email", email });
return ok;
}
const rawToken = crypto.randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(rawToken).digest("hex");
const expiresAt = new Date(Date.now() + 30 * 60 * 1000);
await db.passwordResetTokens.invalidateOpenForUser(user.id);
await db.passwordResetTokens.insert({
userId: user.id,
tokenHash,
expiresAt,
});
const resetUrl = `https://app.example.com/reset-password?token=${rawToken}`;
await sendPasswordResetEmail({
to: user.email,
resetUrl,
expiresMinutes: 30,
});
return ok;
}
Step 2 — build HTML you control
Transactional auth mail should be boring: clear subject, one primary link, plain-text fallback tone, no tracking gimmicks that break in corporate filters.
function resetEmailHtml(resetUrl: string, expiresMinutes: number) {
return `<!doctype html>
<html>
<body style="font-family: system-ui, sans-serif; line-height: 1.5; color: #111;">
<p>We received a request to reset your password.</p>
<p><a href="${resetUrl}">Choose a new password</a></p>
<p>This link expires in ${expiresMinutes} minutes. If you did not request it, you can ignore this email.</p>
<p style="color:#666;font-size:14px;">If the button does not work, paste this URL into your browser:<br>${resetUrl}</p>
</body>
</html>`;
}
Keep subjects predictable, for example Reset your Example password. Avoid “Urgent!!!” patterns that look like phishing.
Step 3 — send with Notify
Notify’s send endpoint is POST https://notify.cx/api/email/send. Authenticate with x-api-key. The body needs to, from, subject, and message (HTML or plain text, up to 100,000 characters).
Production from addresses should use a verified domain. Verify SPF, DKIM, and DMARC in the Notify dashboard (or via the domains API), then send from something like security@mail.example.com.
async function sendPasswordResetEmail(opts: {
to: string;
resetUrl: string;
expiresMinutes: number;
}) {
const response = await fetch("https://notify.cx/api/email/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.NOTIFY_API_KEY!,
},
body: JSON.stringify({
from: "Example Security <security@mail.example.com>",
to: opts.to,
subject: "Reset your Example password",
message: resetEmailHtml(opts.resetUrl, opts.expiresMinutes),
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Notify send failed: ${response.status} ${text}`);
}
const payload = (await response.json()) as {
success: boolean;
data?: { messageId?: string };
};
// Persist messageId next to the token row for support / webhook correlation
return payload.data?.messageId;
}
Sandbox first
Before domain verification is complete, use Notify’s test path (POST /api/email/send/test) or the guided dashboard test to rehearse the payload without pretending you are done with DNS. For production custom from values, verification is required.
Free vs Pro for auth mail
Notify’s Free plan includes 1,000 emails/month, one domain, and 48-hour logs. Pro is $10/month for 10,000 emails with permanent logs and webhooks. Password reset volume is usually small; the reason to be on Pro is durable logs plus bounce/complaint webhooks as you grow.
Step 4 — consume the token
export async function resetPassword(req: Request) {
const body = await req.json();
const rawToken = String(body.token || "");
const newPassword = String(body.password || "");
if (rawToken.length < 32 || newPassword.length < 12) {
return Response.json({ error: "Invalid request" }, { status: 400 });
}
const tokenHash = createHash("sha256").update(rawToken).digest("hex");
const row = await db.passwordResetTokens.findValid(tokenHash);
if (!row || row.usedAt || row.expiresAt < new Date()) {
return Response.json({ error: "Reset link is invalid or expired" }, { status: 400 });
}
await db.users.updatePassword(row.userId, await hashPassword(newPassword));
await db.passwordResetTokens.markUsed(row.id);
await db.sessions.revokeAllForUser(row.userId);
return Response.json({ ok: true });
}
After a successful reset, consider sending a second transactional email: “Your password was changed.” That message is also a good Notify send, and it gives the real account owner a chance to react if the reset was malicious.
Step 5 — observe delivery
Save the messageId from Notify’s response. Then:
- Check Email logs in the dashboard when a user says nothing arrived.
- On Pro/Scale, create a webhook subscription for
Delivery,Bounce, andComplaintagainst your verified domain. - On bounce/complaint, suppress future mail to that address and flag the account for support review.
Example webhook creation shape from Notify’s API:
curl -X POST https://notify.cx/api/webhooks \
-H "Content-Type: application/json" \
-H "x-api-key: $NOTIFY_API_KEY" \
-d '{
"webhookUrl": "https://api.example.com/webhooks/notify",
"subscribedEvents": ["Delivery", "Bounce", "Complaint"],
"domainId": "YOUR_DOMAIN_ID"
}'
Your webhook handler should be idempotent. Providers can retry. Key off event id / message id and ignore duplicates.
Common failure modes (and how to debug them)
| Symptom | Likely cause | What to check |
|---|---|---|
| API returns error on send | Unverified from domain or bad API key | Domain status in Notify; x-api-key env on the server |
| User never receives mail | Spam folder, typo’d email, delayed DNS reputation | Notify logs; ask user to search “Reset your”; confirm recipient |
| Link works twice | Token not marked used | used_at update in the same transaction as password change |
| Link fails immediately | Frontend stripped query params / wrong app URL | Log the constructed resetUrl in staging; test mobile mail clients |
| Abuse traffic | Missing rate limits | Per-IP and per-email throttles; CAPTCHA |
Framework notes
The email send is identical across Express, Hono, Nest, Laravel, Rails, or a Next.js Route Handler. What changes is where the secret lives and how you structure the two endpoints.
For Next.js App Router, keep both handlers under app/api/.../route.ts, mark them as server-only, and never import NOTIFY_API_KEY into a Client Component.
For workers (Cloudflare Workers, Fly Machines), the same fetch call works; store the API key as a secret binding.
Why Notify fits this flow
Password reset mail does not need a campaign builder. It needs:
- a stable send API,
- a verified domain so the message looks like it came from you,
- logs when support investigates,
- webhooks when addresses go bad.
That is the Notify product shape: send + observe, with HTML you already generated in code.
Rollout checklist
- Create a Notify account and API key.
- Verify
mail.yourdomain.com(or apex) with SPF, DKIM, DMARC. - Implement hashed, expiring, single-use tokens.
- Ship forgot + reset endpoints with generic responses and rate limits.
- Send via
https://notify.cx/api/email/send. - Store
messageId; confirm a real inbox receive in staging. - Add bounce/complaint webhooks before you scale invites or billing mail on the same domain.
- Send a “password changed” confirmation email after successful resets.
Do those eight and the “email” part of password reset stops being mysterious — it becomes a boring, debuggable dependency, which is exactly what you want.
FAQ
How do I send a password reset email from a backend API?
Generate a secure short-lived token, store only its hash, build a reset URL containing the raw token, then call a transactional email API from your server. With Notify, that is one POST to /api/email/send with to, from, subject, and message.
Should the reset token live in the email or only in the database?
Email the raw token (or a high-entropy opaque string) inside the link. Store a hash in the database. Never store the raw token in plaintext if you can avoid it.
How long should a password reset link last?
Thirty minutes is a solid default for most apps. Shorter (15 minutes) is fine for high-security products; longer than a few hours increases replay risk if a mailbox is compromised.
Do I need to verify a domain with Notify before testing?
You can rehearse with Notify’s test/sandbox flows and dashboard guided send. Production sends from your own from address require a verified domain with SPF, DKIM, and DMARC.
What should I log when a reset email is sent?
Log user id (not the raw token), timestamp, request IP, and the provider messageId. That is enough to debug delivery without turning your logs into a second credential store.
How do I handle bounce events on reset emails?
Subscribe to Notify webhooks for Bounce and Complaint on Pro/Scale. When those fire, suppress the address and avoid repeated sends that damage domain reputation.
Is Notify only for password resets?
No. The same send path works for magic links, email verification, receipts, and product alerts. Password reset is simply the highest-stakes transactional template most apps ship first.
Frequently Asked Questions
Common questions about this topic
How do I send a password reset email from a backend API?
Should the reset token live in the email or only in the database?
How long should a password reset link last?
Do I need to verify a domain with Notify before testing?
What should I log when a reset email is sent?
How do I handle bounce events on reset emails?
Is Notify only for password resets?
Further Reading
Discover more articles on similar topics across our network
Comments
Loading comments…