Hashing Passwords Automatically with Mongoose Pre‑save Middleware
Learn how to hash user passwords automatically with Mongoose pre‑save middleware, see a complete async hook example, and avoid common pitfalls that leave credentials exposed.
03 Jan 2026, 05:22 UTC

Why hash passwords with Mongoose pre‑save
Storing plain‑text passwords in MongoDB exposes credentials if the database is compromised. By attaching a pre‑save hook to a Mongoose schema, the password field is transformed to a bcrypt hash every time a document is created or updated via .save() or .create(). This guarantees that the value persisted is never the raw password.
Worked example
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
password: { type: String, required: true }
});
// pre‑save hook – runs only on .save() and .create()
userSchema.pre('save', async function (next) {
// If this is not a password modification, skip hashing
if (!this.isModified('password')) return next();
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (err) {
// Pass any error to Mongoose so the save fails
next(err);
}
});
const User = mongoose.model('User', userSchema);
module.exports = User;
How the hook works
- Mongoose invokes the pre‑save middleware before emitting the MongoDB write command.
- The hook checks
this.isModified('password')to avoid re‑hashing when other fields change. - If the password is new or changed, it generates a salt, hashes the password, and overwrites
this.passwordwith the hash. - Calling
next()(ornext(err)) tells Mongoose to continue or abort the operation.
Limits and common mistakes
- Scope of the hook – It runs only for
document.save(),Model.create(), and document‑based update methods. Bulk operations such asModel.insertMany(),Model.updateMany(), or rawcollection.update()bypass middleware, so passwords will stay plain‑text unless you hash them manually before calling those methods. - Missing
await– Forgetting to awaitbcrypt.genSaltorbcrypt.hashresults in the hook returning a promise that Mongoose does not wait for; the password field may be saved as a pending promise or the original plain text. - Calling
next()too early – Placingnext()before the async work finishes leads to the same issue as above. - Re‑hashing on every update – Omitting the
isModified('password')check causes the hook to hash an already‑hashed string each time the document is saved, breaking login verification. - Version compatibility – Mongoose 4.x does not support async/await in middleware directly; you must return a promise or use the callback pattern. Using the async pattern on 4.x will silently fail.
- Bcrypt cost factor – The salt rounds (10 in the example) affect CPU time. Too low (e.g., <4) weakens security; too high (e.g., >12) can noticeably slow user registration or login.
Verifying the hash works
- Create a user instance with a plain‑text password.
- Call
await user.save(). - Retrieve the saved document and run
bcrypt.compare(plainText, storedHash); it should returntrue. - As a negative test, try
Model.insertMany([{email:'test@example.com', password:'plain'}])and confirm that the password field remains the plain string (showing the hook did not run).
By following the pattern above and keeping the noted caveats in mind, you can rely on Mongoose to keep passwords safely hashed without scattering manual hashing logic throughout your codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.