How to Send Password Reset Emails Securely
Build a secure password reset email flow — hashed tokens, rate limits, generic responses, and one HTTP send via Notify. Includes Next.js App Router code.
4 min readAlex Rivera
Short answer: To send password reset emails securely, generate a random one-time token on the server, store only a hash with an expiry, email the raw link over HTTPS, always return a generic “if that email exists…” response, and rate-limit by IP and email. Delivery is the easy part: one POST to Notify with an x-api-key and HTML you own.
This guide walks through How to send password reset emails securely end to end — security rules, a Next.js App Router sketch, Notify send code, and deliverability basics. Notify handles send + logs + webhooks. You own tokens, sessions, and password hashing.
Security rules (non-negotiable)
| Rule | Why |
|---|---|
| Random token (32+ bytes) | Guessable tokens get abused |
| Store hash only | DB leak shouldn’t give attackers live reset links |
| Short TTL (15–60 min) | Stolen inbox access has a clock |
| Single-use | Replay after success must fail |
| Generic API response | Don’t leak whether an email is registered |
| Rate limit (email + IP) | Stops inbox flooding and user enumeration loops |
| HTTPS only | Reset links over HTTP are a gift to attackers |
| Server-side API key | Never call the email API from the browser |
Never email the new password. Never log raw tokens in plaintext production logs.
Prerequisites
- Database (or cache) for hashed tokens
NOTIFY_API_KEYon the server (credentials)- Verified domain for production
from(domain verification)
NOTIFY_API_KEY=your_api_key_here
NEXT_PUBLIC_APP_URL=https://yourapp.com
New Notify accounts start sandbox_only until a domain is verified. Rehearse with POST https://notify.cx/api/email/send/test (non-delivering) or the dashboard guided test. Production custom from needs DNS. See sandbox vs production.
Pricing reminder: Free 1,000 emails/mo · Pro $10 / 10,000 · Scale $50 / 100,000 — pricing.
Rate limiting
Throttle “forgot password” by IP and by email so attackers can’t flood inboxes or probe accounts. A simple in-memory starter:
// lib/rate-limit.ts
type Bucket = { count: number; resetAt: number };
const buckets = new Map<string, Bucket>();
export function allowRequest(key: string, limit = 5, windowMs = 15 * 60 * 1000) {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || now > bucket.resetAt) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
if (bucket.count >= limit) return false;
bucket.count += 1;
return true;
}
Swap for Redis in multi-instance production. The policy matters more than the store: five requests per fifteen minutes per email (and per IP) is a reasonable starting point.
Helper: send with Notify
// lib/email.ts
export async function sendEmail(opts: {
to: string;
subject: string;
message: string;
}) {
const apiKey = process.env.NOTIFY_API_KEY;
if (!apiKey) throw new Error('NOTIFY_API_KEY is not set');
const res = await fetch('https://notify.cx/api/email/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
},
body: JSON.stringify({
from: 'noreply@your-verified-domain.com',
to: opts.to,
subject: opts.subject,
message: opts.message
})
});
if (!res.ok) {
throw new Error(`Notify send failed: ${await res.text()}`);
}
}
That’s the whole delivery integration: one HTTPS call. No SDK required.
1. Request reset route
// app/api/auth/forgot-password/route.ts
import { createHash, randomBytes } from 'crypto';
import { allowRequest } from '@/lib/rate-limit';
import { sendEmail } from '@/lib/email';
export async function POST(request: Request) {
const { email } = await request.json();
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
if (!email || typeof email !== 'string') {
return Response.json({ error: 'email is required' }, { status: 400 });
}
if (!allowRequest(`reset:ip:${ip}`) || !allowRequest(`reset:email:${email.toLowerCase()}`)) {
return Response.json({ error: 'Too many requests' }, { status: 429 });
}
// Always return the same shape so you do not leak whether the address exists.
const generic = {
ok: true,
message: 'If that email exists, we sent a reset link.'
};
const user = await findUserByEmail(email);
if (!user) return Response.json(generic);
const token = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await savePasswordResetToken({
userId: user.id,
tokenHash,
expiresAt
});
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${token}`;
try {
await sendEmail({
to: email,
subject: 'Reset your password',
message: `
<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 one hour. If you did not request this, you can ignore this email.</p>
`
});
} catch (err) {
console.error('Failed to send reset email', err);
return Response.json({ error: 'Failed to send email' }, { status: 500 });
}
return Response.json(generic);
}
Replace findUserByEmail / savePasswordResetToken with your database layer (Supabase, Prisma, Drizzle, etc.).
2. Reset page (App Router)
// app/reset-password/page.tsx
'use client';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
export default function ResetPasswordPage() {
const token = useSearchParams().get('token') ?? '';
const [password, setPassword] = useState('');
const [status, setStatus] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password })
});
const data = await res.json();
setStatus(data.message ?? data.error);
}
return (
<form onSubmit={onSubmit}>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="New password"
required
minLength={12}
/>
<button type="submit">Update password</button>
{status && <p>{status}</p>}
</form>
);
}
3. Confirm reset route
// app/api/auth/reset-password/route.ts
import { createHash } from 'crypto';
export async function POST(request: Request) {
const { token, password } = await request.json();
if (!token || !password) {
return Response.json(
{ error: 'token and password are required' },
{ status: 400 }
);
}
if (typeof password !== 'string' || password.length < 12) {
return Response.json({ error: 'Password too short' }, { status: 400 });
}
const tokenHash = createHash('sha256').update(token).digest('hex');
const record = await findValidPasswordResetToken(tokenHash);
if (!record) {
return Response.json(
{ error: 'Invalid or expired reset link' },
{ status: 400 }
);
}
await updateUserPassword(record.userId, password); // hash with bcrypt/argon2
await deletePasswordResetToken(record.id);
return Response.json({ message: 'Password updated' });
}
Invalidate other sessions after a successful reset if your auth stack supports it. Single-use the token even if the password update fails mid-flight — or use a transaction so you don’t leave a spent token with an unchanged password.
Email copy that actually lands
Keep it short. Put the action link early. Avoid “Click here!!!” spam patterns. Send from a verified domain. Separate transactional reputation from any marketing mail (different subdomain is fine: mail.example.com vs hello.example.com).
Watch logs and webhooks for bounces and complaints — a reset flow that quietly fails is a support queue.
FAQ
How to send password reset emails securely — what’s the minimum checklist?
Random token → store hash + expiry → email raw link over HTTPS → generic success response → rate limits → single-use on consume. Delivery via a transactional API; password hashing stays in your app.
Should I use magic links instead?
Same security model, different UX. Magic links and OTPs still need hashed secrets, short TTLs, and rate limits. See magic links & OTP.
Do I need a template studio?
No. Password reset HTML is a few paragraphs and one link. You (or your AI) write it; Notify delivers it.
What if I’m still in Notify sandbox?
Verify a domain for production from, or use the dashboard guided test / /api/email/send/test while you wire the token flow. Don’t ship a client-side API key as a workaround.
Bottom line
Password reset is boring infrastructure done right: hashed tokens, short TTL, no account enumeration, rate limits, and a small send API. Notify is built for that job — Free 1k / Pro $10·10k / Scale $50·100k, one fetch, no marketing suite.
Ship the reset. Then ship the product.
Resources
More in web-development
Cubed
Write about the technologies shaping the future.
For developers, founders, and curious minds exploring AI, crypto, Web3, and emerging tech—signal over noise.
One free account across In Plain English, Stackademic, Venture, and Cubed.
How it works- AI, crypto & Web3
- Software & emerging technologies
- Analysis & practical resources
- Thoughtful voices, not hype
Sign in
Google or GitHub
Complete profile
Takes a few minutes
Get approved & publish
Start sharing
Why write for Cubed?
The future deserves thoughtful voices, not just louder headlines.

Comments
Loading comments…