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)

RuleWhy
Random token (32+ bytes)Guessable tokens get abused
Store hash onlyDB leak shouldn’t give attackers live reset links
Short TTL (15–60 min)Stolen inbox access has a clock
Single-useReplay after success must fail
Generic API responseDon’t leak whether an email is registered
Rate limit (email + IP)Stops inbox flooding and user enumeration loops
HTTPS onlyReset links over HTTP are a gift to attackers
Server-side API keyNever call the email API from the browser

Never email the new password. Never log raw tokens in plaintext production logs.

Prerequisites

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.

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
1

Sign in

Google or GitHub

2

Complete profile

Takes a few minutes

3

Get approved & publish

Start sharing

Why write for Cubed?

The future deserves thoughtful voices, not just louder headlines.

Comments

Loading comments…

Posts Across the Network