How to Build Password Reset Emails That Actually Work
Technical guide to implementing password reset emails. Security best practices, token generation, expiration, and email template design.
Password reset is one of the most critical transactional emails you will send. Get it wrong and users cannot access their accounts. Get it really wrong and you create security vulnerabilities.
This guide covers the complete implementation from token generation to email delivery.
Token Generation
The reset token must be cryptographically secure and unpredictable.
import crypto from 'crypto';
function generateResetToken(): string {
// 32 bytes = 256 bits of entropy
return crypto.randomBytes(32).toString('hex');
}
// Alternative: use a UUID v4
import { randomUUID } from 'crypto';
const token = randomUUID(); Never use:
- User ID or email as the token
- Sequential numbers
- Math.random() or similar non-cryptographic randomness
- Base64 encoded user data
Token Storage
Store a hash of the token, not the token itself. If your database is compromised, attackers cannot use the stored hashes.
import crypto from 'crypto';
async function createPasswordResetToken(userId: string) {
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
// Store the hash, not the token
await db.passwordResetToken.create({
data: {
userId,
tokenHash,
expiresAt
}
});
// Return the plain token to send in email
return token;
}
async function verifyResetToken(token: string) {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const resetToken = await db.passwordResetToken.findFirst({
where: {
tokenHash,
expiresAt: { gt: new Date() },
usedAt: null
},
include: { user: true }
});
return resetToken;
} Expiration and Rate Limiting
async function requestPasswordReset(email: string) {
const user = await db.user.findUnique({ where: { email } });
// Always return success to prevent email enumeration
if (!user) {
return { success: true };
}
// Rate limit: max 3 requests per hour
const recentRequests = await db.passwordResetToken.count({
where: {
userId: user.id,
createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) }
}
});
if (recentRequests >= 3) {
// Still return success to prevent enumeration
return { success: true };
}
// Invalidate any existing tokens
await db.passwordResetToken.updateMany({
where: { userId: user.id, usedAt: null },
data: { usedAt: new Date() }
});
const token = await createPasswordResetToken(user.id);
await sendPasswordResetEmail(user, token);
return { success: true };
} Sending the Email
Using Sequenzy:
import { Sequenzy } from '@sequenzy/sdk';
const sequenzy = new Sequenzy({ apiKey: process.env.SEQUENZY_API_KEY });
async function sendPasswordResetEmail(user: User, token: string) {
const resetLink = `https://app.example.com/reset-password?token=${token}`;
await sequenzy.send({
to: user.email,
template: 'password-reset',
variables: {
userName: user.name || 'there',
resetLink,
expiresIn: '1 hour',
ipAddress: getClientIP(),
userAgent: getUserAgent()
}
});
} Email Content Best Practices
A good password reset email includes:
- Clear subject line: "Reset your password" or "Password reset request"
- Who requested it: IP address and browser (for security awareness)
- Expiration notice: "This link expires in 1 hour"
- What to do if not requested: "If you did not request this, ignore this email"
- Single clear CTA: One button to reset the password
- Plain text fallback: Include the full URL for text-only clients
Example Template
<!-- HTML version -->
<h1>Reset your password</h1>
<p>Hi {{userName}},</p>
<p>We received a request to reset your password.
Click the button below to choose a new password:</p>
<a href="{{resetLink}}"
style="background: #000; color: #fff; padding: 12px 24px;
text-decoration: none; border-radius: 6px;">
Reset Password
</a>
<p>This link expires in {{expiresIn}}.</p>
<p style="color: #666; font-size: 14px;">
Request details:<br>
IP: {{ipAddress}}<br>
Browser: {{userAgent}}
</p>
<p style="color: #666;">
If you did not request this password reset, you can safely ignore
this email. Your password will remain unchanged.
</p> Processing the Reset
async function resetPassword(token: string, newPassword: string) {
const resetToken = await verifyResetToken(token);
if (!resetToken) {
throw new Error('Invalid or expired reset token');
}
// Hash the new password
const passwordHash = await bcrypt.hash(newPassword, 12);
// Update password and mark token as used
await db.$transaction([
db.user.update({
where: { id: resetToken.userId },
data: { passwordHash }
}),
db.passwordResetToken.update({
where: { id: resetToken.id },
data: { usedAt: new Date() }
}),
// Invalidate all sessions
db.session.deleteMany({
where: { userId: resetToken.userId }
})
]);
// Send confirmation email
await sequenzy.send({
to: resetToken.user.email,
template: 'password-changed',
variables: {
userName: resetToken.user.name
}
});
} Security Checklist
- Use cryptographically secure random tokens
- Store hashed tokens, not plain tokens
- Set reasonable expiration (1 hour is common)
- Rate limit reset requests per user
- Invalidate token after use
- Invalidate all sessions after password change
- Send confirmation email after successful reset
- Do not reveal whether email exists (prevent enumeration)
- Log all reset attempts for security audit
Service Recommendations
For reliable password reset email delivery:
Need reliable email delivery?
Compare transactional email services for your application.
View Full Comparison