DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Your Mongoose pre('save') Hook Is Being Silently Skipped by Half Your Update Calls

This is one of the more consequential Mongoose gotchas because of exactly what tends to live inside a pre('save') hook, password hashing, slug generation, validation logic, and exactly which methods silently bypass it without ever telling you.

The Setup

// models/User.ts
const userSchema = new Schema({
  email: String,
  password: String,
});

userSchema.pre('save', async function (next) {
  if (this.isModified('password')) {
    this.password = await bcrypt.hash(this.password, 12);
  }
  next();
});

export const User = model('User', userSchema);
Enter fullscreen mode Exit fullscreen mode

Create a new user with User.create() or new User(data).save(), and this works exactly as intended. The password arrives hashed, every time, without the rest of your codebase needing to remember to call bcrypt.hash manually anywhere. It feels like a solved problem.

Where It Actually Breaks

// A password reset flow
export async function resetPassword(userId: string, newPassword: string) {
  await User.findByIdAndUpdate(userId, { password: newPassword });
}
Enter fullscreen mode Exit fullscreen mode

This runs without error. It updates the user's password field in the database. It also never calls pre('save'), which means the password gets stored in plain text, directly in your database, with no error, no warning, and a function name, findByIdAndUpdate, that reads as a completely reasonable, idiomatic way to update a user record.

findByIdAndUpdate, findOneAndUpdate, updateOne, and updateMany all operate directly against MongoDB's update commands. They don't instantiate a full Mongoose document and call .save() on it internally, they issue an update operation straight to the database. Mongoose's document middleware, including pre('save'), only runs on document instances that actually go through .save(). These query-based update methods structurally never touch that code path at all.

Why This Specifically Gets Missed

Both approaches, .save() and findByIdAndUpdate, look like equally valid, equally idiomatic ways to update a Mongoose document, and most Mongoose tutorials use both interchangeably without flagging that they behave completely differently with respect to middleware. A codebase that uses .save() for creating users and findByIdAndUpdate for updating them, which is an extremely common and reasonable-looking pattern, ends up with password hashing that works perfectly at signup and silently doesn't exist at all for a password reset, profile update, or admin-initiated password change, unless someone happens to specifically test that exact path with a tool that shows raw database contents.

The Fix: Use Query Middleware, Not Just Document Middleware

Mongoose provides a separate middleware hook specifically for query-based update methods:

userSchema.pre('findOneAndUpdate', async function (next) {
  const update = this.getUpdate() as any;

  if (update.password) {
    update.password = await bcrypt.hash(update.password, 12);
  }

  next();
});
Enter fullscreen mode Exit fullscreen mode

This specifically targets findOneAndUpdate and findByIdAndUpdate (which is implemented as a wrapper around findOneAndUpdate internally). updateOne and updateMany need their own separate middleware registration, since Mongoose treats each query method as its own distinct middleware hook rather than one unified "any kind of update" event:

userSchema.pre('updateOne', async function (next) {
  const update = this.getUpdate() as any;
  if (update.password) {
    update.password = await bcrypt.hash(update.password, 12);
  }
  next();
});
Enter fullscreen mode Exit fullscreen mode

A More Defensive Alternative: Standardize on One Update Path

Registering middleware separately for every query method Mongoose offers is viable but easy to let drift out of sync as new update call sites get added later by someone who doesn't know this distinction exists. A more durable fix for anything genuinely sensitive, like password handling, is routing all updates through a single service function that always uses .save() internally, rather than letting findByIdAndUpdate calls for that model spread throughout the codebase:

// services/userService.ts
export async function updateUser(userId: string, data: Partial<UserInput>) {
  const user = await User.findById(userId);
  if (!user) throw new Error('User not found');

  Object.assign(user, data);
  await user.save(); // always goes through pre('save') middleware
  return user;
}
Enter fullscreen mode Exit fullscreen mode

This trades a small amount of performance, an extra read before the write, for a guarantee that every update for this model goes through the exact same validation and transformation logic, with no separate middleware registration required for each query method and no risk of a future findByIdAndUpdate call quietly skipping it.

The Rule

Anything inside a pre('save') hook only runs for updates that go through .save(), never for findOneAndUpdate, findByIdAndUpdate, updateOne, or updateMany. For any logic where skipping it has real consequences, password hashing being the clearest example, either register the equivalent query middleware for every update method your codebase actually uses, or standardize on a single service function that always goes through .save().

I route every sensitive field update through a dedicated service layer exactly like this in the auth code behind my Next.js and MongoDB templates, specifically because this gap is easy to introduce the moment a second developer, or a future version of yourself, reaches for the more convenient-looking findByIdAndUpdate call.

Get the templates: https://pixelanas.gumroad.com

Go check your own password reset, profile update, and admin edit flows for a findByIdAndUpdate or updateOne call on a model with password hashing in pre('save'). If you find one, check what's actually sitting in that field in your database right now. Drop what you find in the comments.


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (2)

Collapse
 
mercerk17 profile image
Kai Mercer •

would you add a reset test using {$set: {password: newPassword}} too? the hook checks update.password, so that update shape looks worth testing before treating the query middleware as the fix

Collapse
 
anas_sheikh_2 profile image
Anas Sheikh •

Good catch! You're right — {$set: { password: newPassword }} won't be caught by a check for update.password. The middleware needs to handle both direct updates and $set updates, and test both cases. For password handling, I'd still prefer a dedicated service that routes updates through .save() to keep the logic consistent.