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
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 }; }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.