Automating Password Hashing with Mongoose Pre‑Save Hooks
Learn how to hash passwords automatically before they are saved, avoid re‑hashing on updates, and understand where the hook does not run.
03 Oct 2025, 06:40 UTC

When you store user credentials, hashing the password before it reaches MongoDB is essential. Doing this logic in every controller creates duplication and risk of omission. Mongoose middleware (hooks) lets you centralize the hash operation so it runs automatically whenever a document is saved.
Desired Outcome
After completing this guide, your User schema will:
- Hash a plain‑text password with bcrypt before the document is persisted.
- Skip re‑hashing when the password field has not changed.
- Return a clear error if the hashing process fails, aborting the save operation.
Prerequisites
You need the following installed in your Node.js project:
- Mongoose version 6.x or later
- Bcrypt version 5.x or later
- Access to a running MongoDB instance (local or remote)
Focused Procedure
- Install dependencies (run in your project root):
npm install mongoose bcrypt - Create the schema and attach an asynchronous
pre('save')hook:
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
password: { type: String, required: true }
});
// Pre‑save hook: hash password only if it is new or modified
userSchema.pre('save', async function (next) {
// `this` refers to the document being saved
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 operation fails
next(err);
}
});
const User = mongoose.model('User', userSchema);
module.exports = User;
- Connect to MongoDB and test the hook:
const mongoose = require('mongoose');
const User = require('./userModel');
async function demo() {
await mongoose.connect('mongodb://localhost:27017/testdb', {
useNewUrlParser: true,
useUnifiedTopology: true
});
// Create a new user – password should be hashed
const newUser = new User({ username: 'alice', password: 'secret123' });
await newUser.save();
// Retrieve and verify
const saved = await User.findOne({ username: 'alice' });
console.log('Stored password:', saved.password); // looks like a hash
const matches = await bcrypt.compare('secret123', saved.password);
console.log('Password matches:', matches); // true
// Update a non‑password field – hook should NOT re‑hash
saved.username = 'alice_updated';
await saved.save();
const afterUpdate = await User.findOne({ username: 'alice_updated' });
console.log('Password after username change:', afterUpdate.password); // unchanged hash
await mongoose.disconnect();
}
demo().catch(console.error);
Expected Checks
- After
save(), thepasswordfield in MongoDB is a bcrypt hash (starts with$2b$or similar). - Calling
bcrypt.comparewith the original plain text returnstrue. - Updating a document without changing
passwordleaves the hash unchanged. - If
bcrypt.hashthrows, the error is passed tonext()and the save operation is rejected.
Limitations and Recovery Options
The document‑level pre('save') hook does not run for query‑level update methods such as updateOne, updateMany, findOneAndUpdate, or replaceOne. These operations bypass the Mongoose document lifecycle and talk directly to the MongoDB collection.
If you must use those methods, you have two practical approaches:
- Load the document first, modify it, then call
save()so the hook executes:
const user = await User.findById(id);
user.password = await bcrypt.hash('newPlain', 10); // or keep existing if unchanged
await user.save();
- Manually hash the password before calling the update method and omit the hook:
const hash = await bcrypt.hash('newPlain', 10);
await User.updateOne({ _id: id }, { $set: { password: hash } });
Choose the approach that fits your workflow; the first preserves all middleware logic, the second is faster but requires you to duplicate the hashing step.
Practical Way to Verify the Result
After any save operation, run this quick check in a MongoDB shell or via Mongoose:
db.users.findOne({ username: 'alice' }).passwordIf the value looks like a random string beginning with
$2b$(or$2a$,$2y$), the hook worked. You can also run:const user = await User.findOne({ username: 'alice' }); const ok = await bcrypt.compare('secret123', user.password); console.log('Hash valid:', ok); // should print true
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.