Skip to main content
Password reset emails are the most common transactional email type and one of the most security-sensitive. A poorly implemented reset flow can lock users out of their accounts, expose tokens to attackers, or land in spam at the worst possible moment. This guide covers the full lifecycle — from generating the reset link to delivering it reliably.

Why Password Resets Are Unique

Password reset emails differ from other transactional emails in several ways:
FactorImpact
Time-sensitiveUsers expect delivery within seconds, not minutes
Security-criticalThe email contains a credential-equivalent token
High-engagementUsers actively check their inbox, so deliverability is rarely a perception problem — but failures are immediately noticed
Single-useThe token should only work once and expire quickly
Triggered by the userThe recipient explicitly requested this email, so spam complaints are rare

Token Security

The reset token is the most important part of the email. A weak or poorly managed token can let attackers take over accounts.

Generate Cryptographically Secure Tokens

Use your language’s cryptographically secure random generator — never Math.random() or similar predictable sources:
const crypto = require('crypto');

// Generate a 32-byte (256-bit) token
const token = crypto.randomBytes(32).toString('hex');
// Example: "a3f8c1d9e4b2...64 hex characters"
// Laravel — built-in password broker handles this
// If generating manually:
$token = bin2hex(random_bytes(32));

Set Short Expiry Times

Reset tokens should expire quickly. The user just requested the email, so they’re actively waiting for it:
ExpiryRecommendation
15–60 minutesRecommended range
30 minutesGood default
24 hoursToo long — increases the window for token interception
const resetExpiry = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes

await db.passwordResets.create({
  email: user.email,
  token: hashedToken,
  expiresAt: resetExpiry,
});
Store a hashed version of the token in your database, not the raw token. If your database is compromised, raw tokens would allow attackers to reset any pending account.

Invalidate Tokens After Use

A token should become invalid the moment it’s used. Also invalidate any existing tokens when a new reset is requested:
async function handlePasswordReset(token, newPassword) {
  const reset = await db.passwordResets.findByToken(hash(token));

  if (!reset || reset.expiresAt < new Date()) {
    throw new Error('Invalid or expired reset link');
  }

  await db.users.updatePassword(reset.email, newPassword);
  await db.passwordResets.deleteAllForEmail(reset.email);
  // All previous tokens for this user are now invalid
}

Email Content

Keep the Email Simple

A password reset email should contain exactly what the user needs and nothing more:
1

Acknowledge the request

A brief statement confirming that a password reset was requested for their account.
2

Provide the reset link

A clear, prominent button or link to reset their password.
3

State the expiry

Tell the user how long the link is valid so they know to act promptly.
4

Include a safety notice

Let them know to ignore the email if they didn’t request it.

Example HTML Structure

<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
  <h2 style="color: #1a1a1a;">Reset your password</h2>

  <p style="color: #4a4a4a; line-height: 1.6;">
    We received a request to reset the password for your account
    associated with {{email}}.
  </p>

  <div style="text-align: center; margin: 32px 0;">
    <a href="{{resetUrl}}"
       style="background-color: #6366F1; color: #ffffff; padding: 12px 32px;
              text-decoration: none; border-radius: 6px; font-weight: 600;">
      Reset Password
    </a>
  </div>

  <p style="color: #6b6b6b; font-size: 14px; line-height: 1.6;">
    This link expires in 30 minutes. If you didn't request a password reset,
    you can safely ignore this email — your password won't be changed.
  </p>

  <p style="color: #6b6b6b; font-size: 14px; line-height: 1.6;">
    If the button doesn't work, copy and paste this URL into your browser:<br>
    <a href="{{resetUrl}}" style="color: #6366F1; word-break: break-all;">{{resetUrl}}</a>
  </p>
</div>
Always include a plain-text fallback URL below the button. Some email clients block images and styled buttons, and some users prefer to inspect the URL before clicking.

What Not to Include

  • Don’t include the user’s password — not even a temporary one. Always use a link to a secure reset form.
  • Don’t include the username — if the email is intercepted, this reveals the account identifier.
  • Don’t include marketing content — this is a transactional email. Adding promotions risks spam classification and violates CAN-SPAM regulations.
  • Don’t include account details beyond the email address — minimize information exposure.

Sending with Lettr

const response = await fetch('https://app.lettr.com/api/emails', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.LETTR_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    from: 'security@mail.yourdomain.com',
    to: user.email,
    subject: 'Reset your password',
    html: resetEmailHtml,
    text: resetEmailText,
    tags: ['password-reset'],
    metadata: {
      userId: user.id,
      resetRequestId: requestId,
    },
  }),
});
Use a from address on a subdomain (like mail.yourdomain.com) rather than your root domain. This isolates your transactional sending reputation from any marketing email you may send. See Subdomain vs Root Domain for details.

Delivery Speed

Password resets are one of the few email types where delivery speed is immediately noticeable. Users are staring at their inbox waiting.

Optimize for Fast Delivery

  • Send immediately — don’t queue password reset emails behind bulk sends. Use a separate sending priority or queue if your system batches emails.
  • Keep the email small — avoid large images, heavy templates, or excessive HTML. A lightweight email processes faster at every stage of the delivery pipeline.
  • Use a reputable sending domain — domains with established reputation are processed faster by receiving servers. See Sending Reputation.
  • Monitor delivery time — use Lettr webhooks to track the time between your API call and the email.delivered event. Alert if this exceeds a few seconds.
// Track delivery latency via webhooks
app.post('/webhooks/lettr', express.raw({ type: 'application/json' }), (req, res) => {
  const event = JSON.parse(req.body);

  if (event.type === 'email.delivered' && event.data.tags?.includes('password-reset')) {
    const latency = new Date(event.data.deliveredAt) - new Date(event.data.sentAt);
    metrics.recordDeliveryLatency('password-reset', latency);

    if (latency > 10000) { // More than 10 seconds
      alertOps('Slow password reset delivery detected');
    }
  }

  res.sendStatus(200);
});

Handling Edge Cases

User Doesn’t Receive the Email

Provide a “Resend” button on your password reset page, but rate-limit it:
  • Allow a maximum of 3 reset emails per email address per hour
  • Show a generic message regardless of whether the email exists in your system (prevents account enumeration)
// Don't reveal whether the email exists
app.post('/api/password-reset', async (req, res) => {
  const { email } = req.body;

  // Always return the same response
  res.json({ message: 'If an account exists with that email, a reset link has been sent.' });

  // Process in the background
  const user = await db.users.findByEmail(email);
  if (user && !isRateLimited(email)) {
    await sendPasswordResetEmail(user);
  }
});

Expired Token

When a user clicks an expired link, show a clear message and offer to send a new one:
  • Don’t just say “invalid link” — tell them the link has expired
  • Provide a button to request a new reset email
  • Don’t auto-send a new email without user action

Reset from an Unrecognized Device

Consider sending a follow-up notification after a successful password change:
// After password is successfully changed
await fetch('https://app.lettr.com/api/emails', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.LETTR_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    from: 'security@mail.yourdomain.com',
    to: user.email,
    subject: 'Your password was changed',
    html: passwordChangedHtml,
    text: passwordChangedText,
    tags: ['password-changed'],
  }),
});
This alerts the user if someone else reset their password, giving them a chance to act.

Common Mistakes

Tokens generated with Math.random(), auto-incrementing IDs, or timestamps are guessable. Always use crypto.randomBytes() or your language’s equivalent CSPRNG.
If you store raw tokens in your database, a database breach exposes all pending resets. Hash tokens before storage using SHA-256 or bcrypt.
A 24-hour or never-expiring token gives attackers a large window. Use 30–60 minutes as the default.
Never email a password — temporary or otherwise. Always send a link to a form where the user sets a new password over HTTPS.
If your reset endpoint returns different responses for existing vs non-existing emails, attackers can enumerate your user base. Always return the same message.
Password reset emails are transactional. Adding promotional content can trigger spam filters and may violate CAN-SPAM. Keep the email focused.