Vanessa LozzardoHow to build a password reset flow with transactional email
A complete guide to building secure password reset flows — token generation, email sending, expiration handling, and security best practices.

Password reset is the single most security-critical email your application sends. Get it wrong and you hand attackers a backdoor. Get it right and users barely think about it — they click a link, set a new password, and move on.
This guide covers the entire password reset flow: token generation, secure storage, email delivery with Sendkit, verification, and the mistakes that get teams breached.
The flow
Every password reset follows the same sequence:
- User submits their email on your "Forgot password" form
- Your server generates a cryptographically random token
- You hash the token and store it with an expiration timestamp
- You send the unhashed token in a reset link via email
- User clicks the link
- Your server hashes the incoming token, looks it up, checks expiration
- User sets a new password
- You invalidate the token
Each step has security implications. Skip one and you create a vulnerability.
Generate a secure token
The token is the core of the entire flow. It must be unpredictable.
import crypto from 'node:crypto';
const generateResetToken = () => {
const token = crypto.randomBytes(32).toString('hex');
const hash = crypto.createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 20 * 60 * 1000); // 20 minutes
return { token, hash, expiresAt };
};Use crypto.randomBytes, not Math.random. The difference matters: Math.random is not cryptographically secure. An attacker who knows the internal state can predict future outputs. crypto.randomBytes pulls from the OS entropy pool and produces values that are computationally infeasible to guess.
Hash the token with SHA-256 before storing it in your database. Store only the hash. If your database leaks, attackers get hashes — not usable tokens.

Store the token with an expiration
Your database record should look like this:
const storeResetToken = async (userId, hash, expiresAt) => {
// Invalidate any existing tokens for this user first
await db.passwordResets.deleteMany({ userId });
await db.passwordResets.create({
userId,
tokenHash: hash,
expiresAt,
used: false,
});
};Set expiration between 15 and 30 minutes. Anything longer gives attackers a wider window. Anything shorter frustrates users who check email on their phone. Twenty minutes is the sweet spot.
Always delete existing tokens before creating a new one. One active token per user at a time. No exceptions.
Rate limit reset requests
Without rate limiting, an attacker can flood any email address with reset emails or brute-force tokens. Add limits at two levels:
import rateLimit from 'express-rate-limit';
const resetLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 3, // 3 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/forgot-password', resetLimiter, async (req, res) => {
const { email } = req.body;
// Always return the same response regardless of whether the email exists
res.json({ message: 'If that email is registered, a reset link has been sent.' });
const user = await db.users.findByEmail(email);
if (!user) return;
const { token, hash, expiresAt } = generateResetToken();
await storeResetToken(user.id, hash, expiresAt);
await sendResetEmail(user.email, token);
});Two things to note here. First, the response is identical whether the email exists or not. This prevents account enumeration — attackers cannot use your reset endpoint to discover which emails are registered. Second, the actual work happens after the response. The user sees a generic confirmation either way.
Send the reset email with Sendkit
The reset email must arrive fast. If it takes two minutes, users assume it failed and submit again. Transactional email infrastructure like Sendkit is built for this — delivery in seconds, not minutes.
Install the SDK:
npm install @sendkitdev/sdkBuild the send function:
import { Sendkit } from '@sendkitdev/sdk';
const sendkit = new Sendkit('sk_live_your_api_key');
const sendResetEmail = async (email, token) => {
const resetUrl = `https://yourapp.com/reset-password?token=${token}`;
const { data, error } = await sendkit.emails.send({
from: '[email protected]',
to: email,
subject: 'Reset your password',
html: `
<div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
<h2>Password reset request</h2>
<p>We received a request to reset the password for the account associated with <strong>${email}</strong>.</p>
<p>Click the button below to choose a new password:</p>
<a href="${resetUrl}" style="display: inline-block; background: #1a1a2e; color: #ffffff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600;">
Reset password
</a>
<p style="margin-top: 24px; font-size: 14px; color: #666;">
This link expires in 20 minutes. If you didn't request this, ignore this email — your password won't change.
</p>
<p style="font-size: 12px; color: #999;">
If the button doesn't work, copy and paste this URL into your browser:<br/>
<a href="${resetUrl}" style="color: #999;">${resetUrl}</a>
</p>
</div>
`,
});
if (error) {
console.error('Failed to send reset email:', error.message);
throw new Error('Email delivery failed');
}
return data.id;
};The email content follows a few deliberate patterns. It shows which account the reset is for so users with multiple accounts know which one triggered it. The CTA button is large and obvious. The expiration is stated clearly. There is a plaintext URL fallback for email clients that strip buttons. And the "if you didn't request this" line reduces support tickets.
Never log the token or the full reset URL. If your logs are compromised, every pending reset becomes exploitable. Log the Sendkit message ID (data.id) instead — that gives you enough to debug delivery issues.
For more on structuring transactional emails, see our guide on sending transactional email with Node.js.
Verify the token and reset the password
When the user clicks the link and submits a new password, verify the token:
app.post('/api/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const resetRecord = await db.passwordResets.findOne({
tokenHash,
used: false,
});
if (!resetRecord) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
if (new Date() > resetRecord.expiresAt) {
await db.passwordResets.delete({ id: resetRecord.id });
return res.status(400).json({ error: 'Invalid or expired token' });
}
const hashedPassword = await bcrypt.hash(newPassword, 12);
await db.users.update({ id: resetRecord.userId }, { password: hashedPassword });
// Invalidate the token
await db.passwordResets.update({ id: resetRecord.id }, { used: true });
// Invalidate all existing sessions for this user
await db.sessions.deleteMany({ userId: resetRecord.userId });
res.json({ message: 'Password updated successfully' });
});Hash the incoming token the same way you hashed it during generation. Look up the hash, not the raw token. Check expiration server-side — never trust client-side timers.
After a successful reset, invalidate all existing sessions. If an attacker had access to the account, you want to kick them out immediately.

Common mistakes that get you breached
Predictable tokens. Using auto-incrementing IDs, UUIDs v1 (which are time-based), or short numeric codes. An attacker can enumerate or brute-force these. Use 32+ bytes of crypto.randomBytes.
No expiration. A token that never expires is a permanent backdoor sitting in someone's inbox. Set 15-30 minutes and enforce it.
Storing raw tokens. If your database leaks and you stored unhashed tokens, every pending reset is compromised. Always store hashes.
Leaking tokens in logs. Your logging middleware captures request bodies or URLs that contain the token. Now your log aggregator has a copy of every reset token. Exclude sensitive fields explicitly.
No rate limiting. Without it, an attacker can request thousands of resets per second, flooding a user's inbox or brute-forcing tokens. Three requests per 15 minutes per IP is reasonable.
Account enumeration. Responding differently when an email exists vs. doesn't ("We sent a reset link" vs. "Email not found") tells attackers which accounts are valid. Always return the same message.
Not invalidating sessions. The user resets their password because they suspect compromise. If you don't kill existing sessions, the attacker stays logged in.
Why transactional email infrastructure matters here
Password resets are time-sensitive. A user who waits more than 30 seconds for the email will try again, creating confusion with multiple tokens. If the email lands in spam, they may never complete the reset and will contact support instead.
This is exactly why you need purpose-built transactional email delivery. Marketing email services batch-send and optimize for throughput. Transactional services like Sendkit optimize for speed and inbox placement. Your reset email should arrive within seconds, every time.
Proper email authentication (SPF, DKIM, DMARC) also matters here. If your domain isn't authenticated, reset emails are more likely to be flagged. We cover this in detail in our email deliverability guide.
Wrapping up
A password reset flow is straightforward to build but easy to get wrong in ways that create real security vulnerabilities. Use cryptographically random tokens, hash before storing, enforce short expiration windows, rate limit requests, and never leak tokens in logs or error messages.
For the email delivery piece, Sendkit handles the hard part — fast, reliable delivery that lands in the inbox, not spam. Check our pricing to get started, or read the docs for the full SDK reference.
Share this article