Use case

Password reset emails

Send password reset emails that land in the inbox, validate safely, and never leak whether an address has an account.

Password reset is the highest-stakes transactional email a SaaS sends. Get it wrong and your customers can't sign back in; get the security wrong and you've handed an attacker a credential-takeover path. The mechanics are not complicated — a single-use token, a short TTL, an idempotent send call, a verification path that does not depend on the email recipient — but every team gets pieces of it wrong in different ways. This page is the implementation we recommend and ship by default in the SNDR-built example apps.

Anatomy

What every solid implementation has

  • 1An endpoint that accepts an email address and ALWAYS returns 200 — never 404 on unknown accounts, or you have just enabled enumeration.
  • 2A cryptographically random token, 256 bits of entropy minimum, generated server-side.
  • 3A persisted record linking { token, user_id, expires_at } with a 15-30 minute TTL.
  • 4A send call that includes the reset link in plain text AND HTML, never just one.
  • 5An idempotency key per (user_id, hour-or-fewer-bucket) so a frantic double-click does not send two emails.
  • 6A click-through handler that exchanges the token for a session capable of password change, then deletes the token (one-use).
  • 7Rate limiting per email-address and per source IP to bound abuse cost.

Implementation

The actual code

  1. Issue the reset token + send the email

    Always respond 200 regardless of whether the account exists. Persist the token with a tight TTL. Idempotency key bucketed by (user, hour) prevents accidental double-send without blocking legitimate re-requests.

    import { Sndr } from '@rkiza/sndr';
    import { randomBytes, createHash } from 'node:crypto';
    
    const sndr = new Sndr(process.env.SNDR_API_KEY!);
    
    export async function requestPasswordReset(email: string) {
      // Look up the user — but DO NOT branch on existence in the response.
      const user = await db.user.findUnique({ where: { email } });
    
      if (user) {
        const raw = randomBytes(32).toString('base64url');
        const hashed = createHash('sha256').update(raw).digest('hex');
        const expiresAt = new Date(Date.now() + 30 * 60_000);
    
        await db.passwordResetToken.create({
          data: { user_id: user.id, token_hash: hashed, expires_at: expiresAt },
        });
    
        const link = `https://yourdomain.com/auth/reset?token=${raw}`;
        const hourBucket = Math.floor(Date.now() / 3_600_000);
    
        await sndr.emails.send(
          {
            from: 'auth@yourdomain.com',
            to: email,
            subject: 'Reset your password',
            // Plaintext alternative is required for deliverability —
            // most spam filters penalise HTML-only mail.
            text: `Click here to reset your password:\n\n${link}\n\nLink expires in 30 minutes. If you did not request this, ignore this email.`,
            html: `<p>Click <a href="${link}">here to reset your password</a>.</p><p>Link expires in 30 minutes. If you did not request this, ignore this email.</p>`,
            headers: {
              // Tell Gmail/Outlook this is a one-off transactional message,
              // not a marketing send. Helps deliverability for new senders.
              'X-Auto-Response-Suppress': 'OOF, DR, RN, NRN, AutoReply',
            },
          },
          { idempotencyKey: `pwreset-${user.id}-${hourBucket}` },
        );
      }
    
      // Always 200, always identical body. Never leak existence.
      return { ok: true };
    }
  2. Validate the token + let the user pick a new password

    Hash incoming tokens before lookup so an attacker who reads your DB cannot replay them. Delete the token row on success — one-use only. Reject expired tokens with a uniform error.

    import { createHash } from 'node:crypto';
    
    export async function consumePasswordResetToken(raw: string, newPassword: string) {
      const hashed = createHash('sha256').update(raw).digest('hex');
    
      // Atomic find-and-delete prevents two parallel requests both succeeding.
      const row = await db.passwordResetToken.findUnique({ where: { token_hash: hashed } });
      if (!row || row.expires_at < new Date()) {
        throw new Error('invalid_or_expired_token');
      }
    
      await db.$transaction([
        db.user.update({
          where: { id: row.user_id },
          data: { password_hash: await hashPassword(newPassword) },
        }),
        db.passwordResetToken.delete({ where: { id: row.id } }),
        // Optional but recommended: invalidate every active session for this
        // user — a successful password reset implies someone else may have
        // been the requester.
        db.session.deleteMany({ where: { user_id: row.user_id } }),
      ]);
    }

Production considerations

What you have to get right

Deliverability

Password reset must land. Always ship a plaintext alternative, use a dedicated subdomain for auth (e.g. auth.yourdomain.com), publish SPF / DKIM / DMARC for it, and watch suppression-list rebuilds during incidents.

Never leak account existence

The request endpoint must return identical status + body whether the email exists or not. Otherwise an attacker can enumerate your user list address by address.

Token hygiene

32+ bytes of entropy. Hash before persisting (so a DB read does not equal a takeover). Single-use — delete after exchange. Tight TTL (15-30 min). One token per user at a time (invalidate previous on new request).

Rate limiting

Per-email and per-IP throttling. A modest rate (5 / hour / address, 30 / hour / IP) is enough to bound abuse without breaking legitimate cases. Consider exponential cool-down on the email side specifically.

Session invalidation

Successful password change should invalidate every active session for the account. Otherwise a previously-compromised session survives a password change — which defeats the whole point.

Idempotent send

A frantic double-click on "send me a reset link" should not produce two emails. The SDK auto-generates an Idempotency-Key per call; for password reset specifically pin it to (user_id, hour) so re-requests get a fresh email after an hour but not within seconds.

FAQ

What if the user requests a reset and you do not have an account?

Return 200 with an identical response body. Optionally, send an email to the address noting that someone tried to reset a password for an account that does not exist — this is a great way to surface phishing attempts to legitimate users and adds zero security risk.

Should the email link work multiple times?

No. Single-use, always. The click-through handler must delete (or atomically mark as consumed) the token row so a leaked link cannot be replayed.

Should I use a magic-link sign-in instead?

For some products yes — fewer passwords means fewer reset flows. See the magic-link page for the equivalent implementation. Many apps offer both.

How long should tokens live?

Long enough that a user in a different timezone receiving the email overnight can still use it (30-60 minutes), short enough that a leaked email does not give an attacker a multi-day window. We default to 30 minutes.

Related

Ship this in production today.

Free tier includes 3,000 emails / month. No card required.

Start building

Your next message starts here.

Connect your domain, create an API key, and send with SNDR.