Password reset
OTP-based password reset via Resend. Email enumeration is prevented - the same response is always returned regardless of whether the email exists.
Flow
- User submits email via ForgotPasswordForm
- sendPasswordResetOTPAction() rate-limits by email
- User existence checked - if not found, still returns success (enumeration prevention)
- If found: OTP generated, SHA-256 hashed, saved with purpose: 'password-reset'
- sendPasswordResetEmail() delivers OTP via Resend
- User navigates to /reset-password (link shown after form submission)
- User submits email + OTP + new password via ResetPasswordForm
- resetPasswordAction() validates OTP same as email verification
- OTP marked used
- User's password field updated - bcrypt pre-save hook re-hashes automatically
- Client redirected to /login with success toast
Email enumeration prevention
A common vulnerability in forgot-password flows is returning different responses for registered vs unregistered emails. This lets an attacker enumerate which emails have accounts.
NextForge always returns:
return {
success: true,
message: 'If that email exists, a code was sent.'
}This response is returned whether or not the user exists. The email is only sent if a matching user is found - but the client never knows which case occurred.
Forgot password action
actions/email.ts
export async function sendPasswordResetOTPAction(
formData: FormData
): Promise<ActionState> {
const raw = { email: formData.get('email') }
const sanitized = sanitizeAndStrip(raw)
const parsed = parseSchema(forgotPasswordSchema, sanitized)
if (!parsed.success) return parsed
const limited = await checkRateLimit(otpRatelimit, sanitized.email)
if (!limited.success) return limited
try {
await connectMongo()
const user = await UserModel.findOne({ email: sanitized.email })
if (user) {
const otp = generateOTP()
const hashedOTP = hashOTP(otp)
await OTPModel.create({
email: sanitized.email,
hashedOTP,
purpose: 'password-reset',
expiresAt: new Date(Date.now() + OTP_EXPIRY_MINUTES * 60 * 1000),
})
await sendPasswordResetEmail({ to: sanitized.email, name: user.name, otp })
}
return {
success: true,
message: 'If that email exists, a code was sent.',
}
} catch {
return {
success: true,
message: 'If that email exists, a code was sent.',
}
}
}Note that even the catch block returns a success response with the same message. This ensures no information is leaked through error responses either.
Reset password action
actions/email.ts
export async function resetPasswordAction(
formData: FormData
): Promise<ActionState> {
const raw = {
email: formData.get('email'),
otp: formData.get('otp'),
password: formData.get('password'),
confirmPassword: formData.get('confirmPassword'),
}
const sanitized = sanitizeAndStrip(raw)
const parsed = parseSchema(resetPasswordSchema, sanitized)
if (!parsed.success) return parsed
try {
await connectMongo()
const record = await OTPModel.findOne({
email: sanitized.email,
purpose: 'password-reset',
used: false,
expiresAt: { $gt: new Date() },
})
if (!record) {
return { success: false, error: 'Invalid or expired code.' }
}
const valid = verifyOTP(sanitized.otp, record.hashedOTP)
if (!valid) {
return { success: false, error: 'Invalid or expired code.' }
}
record.used = true
await record.save()
const user = await UserModel.findOne({ email: sanitized.email })
if (!user) return { success: false, error: 'User not found.' }
user.password = sanitized.password
await user.save()
return {
success: true,
message: 'Password updated. You can now log in.',
}
} catch {
return { success: false, error: 'Reset failed. Please try again.' }
}
}Security notes
- OTP expiry is 10 minutes - configured in lib/tokens.ts via OTP_EXPIRY_MINUTES
- The used flag prevents the same OTP from being replayed
- password field update triggers the pre-save hook - bcrypt re-hashing is automatic
- Rate limiting applies to both the forgot-password and reset-password endpoints
- The same 'Invalid or expired code.' message is returned for both 'not found' and 'hash mismatch' cases