· 10 min read

How to Build Password Reset Emails That Actually Work

Technical guide to implementing password reset emails. Security best practices, token generation, expiration, and email template design.

Password reset is one of the most critical transactional emails you will send. Get it wrong and users cannot access their accounts. Get it really wrong and you create security vulnerabilities.

This guide covers the complete implementation from token generation to email delivery.

Token Generation

The reset token must be cryptographically secure and unpredictable.

import crypto from 'crypto';

function generateResetToken(): string {
  // 32 bytes = 256 bits of entropy
  return crypto.randomBytes(32).toString('hex');
}

// Alternative: use a UUID v4
import { randomUUID } from 'crypto';
const token = randomUUID();

Never use:

  • User ID or email as the token
  • Sequential numbers
  • Math.random() or similar non-cryptographic randomness
  • Base64 encoded user data

Token Storage

Store a hash of the token, not the token itself. If your database is compromised, attackers cannot use the stored hashes.

import crypto from 'crypto';

async function createPasswordResetToken(userId: string) {
  const token = crypto.randomBytes(32).toString('hex');
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

  const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour

  // Store the hash, not the token
  await db.passwordResetToken.create({
    data: {
      userId,
      tokenHash,
      expiresAt
    }
  });

  // Return the plain token to send in email
  return token;
}

async function verifyResetToken(token: string) {
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

  const resetToken = await db.passwordResetToken.findFirst({
    where: {
      tokenHash,
      expiresAt: { gt: new Date() },
      usedAt: null
    },
    include: { user: true }
  });

  return resetToken;
}

Expiration and Rate Limiting

async function requestPasswordReset(email: string) {
  const user = await db.user.findUnique({ where: { email } });

  // Always return success to prevent email enumeration
  if (!user) {
    return { success: true };
  }

  // Rate limit: max 3 requests per hour
  const recentRequests = await db.passwordResetToken.count({
    where: {
      userId: user.id,
      createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) }
    }
  });

  if (recentRequests >= 3) {
    // Still return success to prevent enumeration
    return { success: true };
  }

  // Invalidate any existing tokens
  await db.passwordResetToken.updateMany({
    where: { userId: user.id, usedAt: null },
    data: { usedAt: new Date() }
  });

  const token = await createPasswordResetToken(user.id);
  await sendPasswordResetEmail(user, token);

  return { success: true };
}

Sending the Email

Using Sequenzy:

import { Sequenzy } from '@sequenzy/sdk';

const sequenzy = new Sequenzy({ apiKey: process.env.SEQUENZY_API_KEY });

async function sendPasswordResetEmail(user: User, token: string) {
  const resetLink = `https://app.example.com/reset-password?token=${token}`;

  await sequenzy.send({
    to: user.email,
    template: 'password-reset',
    variables: {
      userName: user.name || 'there',
      resetLink,
      expiresIn: '1 hour',
      ipAddress: getClientIP(),
      userAgent: getUserAgent()
    }
  });
}

Email Content Best Practices

A good password reset email includes:

  • Clear subject line: "Reset your password" or "Password reset request"
  • Who requested it: IP address and browser (for security awareness)
  • Expiration notice: "This link expires in 1 hour"
  • What to do if not requested: "If you did not request this, ignore this email"
  • Single clear CTA: One button to reset the password
  • Plain text fallback: Include the full URL for text-only clients

Example Template

<!-- HTML version -->
<h1>Reset your password</h1>

<p>Hi {{userName}},</p>

<p>We received a request to reset your password.
Click the button below to choose a new password:</p>

<a href="{{resetLink}}"
   style="background: #000; color: #fff; padding: 12px 24px;
          text-decoration: none; border-radius: 6px;">
  Reset Password
</a>

<p>This link expires in {{expiresIn}}.</p>

<p style="color: #666; font-size: 14px;">
  Request details:<br>
  IP: {{ipAddress}}<br>
  Browser: {{userAgent}}
</p>

<p style="color: #666;">
  If you did not request this password reset, you can safely ignore
  this email. Your password will remain unchanged.
</p>

Processing the Reset

async function resetPassword(token: string, newPassword: string) {
  const resetToken = await verifyResetToken(token);

  if (!resetToken) {
    throw new Error('Invalid or expired reset token');
  }

  // Hash the new password
  const passwordHash = await bcrypt.hash(newPassword, 12);

  // Update password and mark token as used
  await db.$transaction([
    db.user.update({
      where: { id: resetToken.userId },
      data: { passwordHash }
    }),
    db.passwordResetToken.update({
      where: { id: resetToken.id },
      data: { usedAt: new Date() }
    }),
    // Invalidate all sessions
    db.session.deleteMany({
      where: { userId: resetToken.userId }
    })
  ]);

  // Send confirmation email
  await sequenzy.send({
    to: resetToken.user.email,
    template: 'password-changed',
    variables: {
      userName: resetToken.user.name
    }
  });
}

Security Checklist

  • Use cryptographically secure random tokens
  • Store hashed tokens, not plain tokens
  • Set reasonable expiration (1 hour is common)
  • Rate limit reset requests per user
  • Invalidate token after use
  • Invalidate all sessions after password change
  • Send confirmation email after successful reset
  • Do not reveal whether email exists (prevent enumeration)
  • Log all reset attempts for security audit

Service Recommendations

For reliable password reset email delivery:

  • Sequenzy: Unified transactional + marketing with billing integrations
  • Postmark: Best deliverability for critical transactional email
  • Resend: Modern developer experience

Need reliable email delivery?

Compare transactional email services for your application.

View Full Comparison