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

  1. User submits email via ForgotPasswordForm
  2. sendPasswordResetOTPAction() rate-limits by email
  3. User existence checked - if not found, still returns success (enumeration prevention)
  4. If found: OTP generated, SHA-256 hashed, saved with purpose: 'password-reset'
  5. sendPasswordResetEmail() delivers OTP via Resend
  6. User navigates to /reset-password (link shown after form submission)
  7. User submits email + OTP + new password via ResetPasswordForm
  8. resetPasswordAction() validates OTP same as email verification
  9. OTP marked used
  10. User's password field updated - bcrypt pre-save hook re-hashes automatically
  11. 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.',
    }
  }
}

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