Password reset emails users trust (and that land in the inbox)

Build a secure password reset flow: single-use tokens, short expiry, no account enumeration, a clear email template and a confirmation once the password changes.

The password reset email is the master key to your product. Anyone who can read it can take over the account, which is why attackers target it, and why users have learned to be suspicious of it.

A good reset flow has to be secure enough to resist those attackers and clear enough that real users trust it in a few seconds. This guide walks through both, from the moment someone clicks “Forgot password?” to the confirmation email after the change.

The flow at a glance

  1. The user enters their email on the Forgot password page.
  2. Your app always shows the same message, whether or not the account exists.
  3. If it does exist, you create a single-use token and email a reset link.
  4. The user opens the link, chooses a new password, and the token is spent.
  5. You sign out the user’s other sessions and send a “your password was changed” email.

Step 1: Don’t reveal who has an account

After the form is submitted, always respond with the same text:

If an account exists for ada@example.com, we’ve sent a link to reset your password. It expires in 30 minutes.

Showing “No account with that email” instead tells anyone with a list of addresses exactly which of them use your product. Keep the response time similar too: do the account lookup and email sending in the background so the page doesn’t answer noticeably faster for unknown addresses.

Step 2: Create a strong, single-use token

The token in the link must be long, random and impossible to guess:

import { randomBytes, createHash } from 'node:crypto';

const token = randomBytes(32).toString('base64url');            // goes in the email
const tokenHash = createHash('sha256').update(token).digest('hex'); // goes in the database

await db.passwordResets.upsert({
  userId: user.id,
  tokenHash,
  expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes
});

The rules:

  • Store only a hash of the token, so a database leak doesn’t hand out working reset links.
  • One active token per user. Issuing a new one replaces the old one.
  • Short expiry: 15 to 60 minutes is typical.
  • Single use. Delete the token as soon as the password is changed.
  • Invalidate it if the user signs in successfully or changes their email address in the meantime.

Step 3: Send the email immediately

The user is waiting, so send in the request, not in a batch job:

import os
import requests

def send_reset_email(user, token):
    link = f"https://app.yourco.com/reset-password/{token}"
    res = requests.post(
        "https://backend.inbixio.com/api/send-email/",
        headers={"X-API-Key": os.environ["INBIXIO_API_KEY"]},
        json={
            "to_email": user.email,
            "subject": "Reset your YourApp password",
            "message": render_reset_template(name=user.first_name, link=link, minutes=30),
        },
        timeout=10,
    )
    res.raise_for_status()

Send it from a dedicated, authenticated address such as security@yourco.com. In Inbixio, connect that mailbox as its own email server and give the auth service an API key pinned to it, so reset emails come from the address users expect. If you ever remove that server, re-pin the key. See API key security for setting this up.

Step 4: Write a reset email people trust

Phishing emails imitate password resets more than any other message. Make yours unmistakable:

  • Subject: plain and specific, e.g. “Reset your YourApp password”. No urgency, no exclamation marks.
  • One clear button, plus the full link as text for clients that block buttons.
  • The expiry: “This link expires in 30 minutes.”
  • Context: when the request was made and, if you have it, from which browser or city.
  • What to do if it wasn’t them: “If you didn’t ask to reset your password, you can ignore this email. Your password won’t change.”
  • No other links, images or promotions. The fewer links, the easier it is to verify.
<div style="font-family:system-ui,sans-serif;max-width:520px;margin:auto;color:#111;line-height:1.5">
  <p>Hi Ada,</p>
  <p>We received a request to reset the password for your YourApp account.</p>
  <p style="margin:28px 0">
    <a href="https://app.yourco.com/reset-password/TOKEN"
       style="background:#584efe;color:#fff;padding:12px 20px;border-radius:8px;text-decoration:none;font-weight:600">
      Choose a new password
    </a>
  </p>
  <p>This link expires in 30 minutes and can only be used once.</p>
  <p>If you didn't ask to reset your password, you can safely ignore this email.</p>
  <p style="color:#666;font-size:13px">Button not working? Paste this link into your browser:<br>
    https://app.yourco.com/reset-password/TOKEN</p>
</div>

When the user opens the link:

  • Look up the hash of the token, check it hasn’t expired, and show the “new password” form. Don’t sign the user in yet.
  • Keep the token out of third-party requests. Add <meta name="referrer" content="no-referrer"> to the reset page, and don’t load analytics or ad scripts there, so the token in the URL isn’t leaked through referrer headers.
  • On submit, check the new password against your password rules, hash it with a slow algorithm (bcrypt, scrypt or Argon2), delete the token, and sign out every other session.

Step 6: Confirm the change

Always send a short confirmation after a password change:

Your YourApp password was changed on 28 October at 14:32 UTC. If this was you, there’s nothing else to do. If it wasn’t, secure your account right away.

This email is the user’s alarm bell if someone else got in, so never skip it.

Make sure it arrives

A reset email in the spam folder is a support ticket. Before launch:

  • set up SPF, DKIM and DMARC for your sending domain,
  • test with Gmail and Outlook addresses and check the headers show PASS, and
  • watch the Failed Deliveries rate on your Inbixio dashboard after deploying.

Checklist

  • Same response for existing and unknown emails
  • 32-byte random token, only its hash stored
  • 15–60 minute expiry, single use, one active token per user
  • Sent immediately from a dedicated, authenticated sender
  • Clear template: one button, plain link, expiry, “wasn’t you?” line
  • No third-party scripts on the reset page; referrer disabled
  • Other sessions signed out and a confirmation email sent after the change

Send it with Inbixio

Connect your own SMTP server, create a scoped API key and send from any language with one HTTPS request.

Create account See pricing