How to implement password reset in Node.js
Password reset requires generating a time-limited secure token, sending it to the user’s email, and allowing the user to set a new password only when they provide the valid unexpired token. As the creator of CoreUI with 25 years of backend development experience, I implement password reset in every authentication system I build, and the most important security principle is that the token is single-use and expires quickly. The flow has two endpoints: one to request the reset (generates and emails the token) and one to confirm the reset (validates the token and sets the new password). Both endpoints must return generic success messages to prevent email enumeration attacks.
Add password reset fields to your user model.
// prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
password String
resetToken String?
resetTokenExpiresAt DateTime?
}
// src/auth/password-reset.service.js
import crypto from 'crypto'
import bcrypt from 'bcrypt'
import { PrismaClient } from '@prisma/client'
import nodemailer from 'nodemailer'
const prisma = new PrismaClient()
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
})
export async function requestPasswordReset(email) {
const user = await prisma.user.findUnique({ where: { email } })
// Don't reveal whether the email exists
if (!user) return
const token = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
await prisma.user.update({
where: { id: user.id },
data: { resetToken: token, resetTokenExpiresAt: expiresAt }
})
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`
await transporter.sendMail({
from: `"My App" <${process.env.SMTP_FROM}>`,
to: email,
subject: 'Reset your password',
html: `
<h2>Password Reset Request</h2>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${resetUrl}">Reset Password</a>
<p>If you didn't request this, ignore this email. Your password won't change.</p>
`
})
}
export async function resetPassword(token, newPassword) {
const user = await prisma.user.findFirst({
where: { resetToken: token }
})
if (!user) {
throw Object.assign(new Error('Invalid or expired reset token'), { status: 400 })
}
if (!user.resetTokenExpiresAt || user.resetTokenExpiresAt < new Date()) {
throw Object.assign(new Error('Reset token has expired'), { status: 400 })
}
const hashedPassword = await bcrypt.hash(newPassword, 12)
await prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiresAt: null
}
})
}
crypto.randomBytes(32) generates a 64-character hex token. Setting a 1-hour expiry balances security with usability. Nullifying the token after use makes it single-use — preventing replay attacks.
Request and Confirm Routes
// src/auth/auth.router.js
router.post('/forgot-password', async (req, res, next) => {
try {
const { email } = req.body
await requestPasswordReset(email)
// Always succeed to prevent email enumeration
res.json({ message: 'If that email is registered, a reset link has been sent.' })
} catch (err) {
next(err)
}
})
router.post('/reset-password', async (req, res, next) => {
try {
const { token, password } = req.body
if (!password || password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' })
}
await resetPassword(token, password)
res.json({ message: 'Password reset successfully. You can now log in.' })
} catch (err) {
next(err)
}
})
The forgot password endpoint always returns the same message regardless of whether the email exists. This prevents attackers from using it to discover registered emails.
Best Practice Note
This is the same password reset pattern used in CoreUI authentication templates. For additional security, invalidate all existing sessions when a password is reset — you can do this by storing a sessionVersion counter in the user record and incrementing it on password reset. Rate limit the /forgot-password endpoint aggressively (e.g., 3 requests per hour per IP) to prevent email flooding. See how to implement email verification in Node.js for the companion email verification flow.



