Hashing User Passwords with Mongoose Pre‑save Middleware
Learn how to add a Mongoose pre‑save hook that hashes passwords with bcrypt only when they are changed, keeping plaintext credentials out of MongoDB.
06 Dec 2025, 04:49 UTC

Desired outcome
Store user passwords as bcrypt hashes in MongoDB using a Mongoose pre('save') middleware so that the plaintext password never touches the database.
Prerequisites
- Node.js ≥14 installed
- MongoDB server accessible (local or remote)
- Mongoose package ≥6.0 installed (
npm i mongoose) - bcrypt package installed (
npm i bcrypt) - Basic familiarity with defining Mongoose schemas
Procedure
- Set up bcrypt configuration – define a constant for salt rounds, preferably from an environment variable.
- Create the user schema and attach a
pre('save')hook that hashes the password only when it is new or modified. - Connect to MongoDB and use the model in your application code.
const SALT_ROUNDS = parseInt(process.env.BCRYPT_SALT_ROUNDS, 10) || 12;
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 },
});
userSchema.pre('save', async function (next) {
// Only hash if the password field is new or has been changed
if (!this.isModified('password')) return next();
try {
const hash = await bcrypt.hash(this.password, SALT_ROUNDS);
this.password = hash;
return next();
} catch (err) {
// Pass any error to Mongoose to abort the save operation
return next(err);
}
});
module.exports = mongoose.model('User', userSchema);
const mongoose = require('mongoose');
const User = require('./userModel');
mongoose.connect(process.env.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true })
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('Connection error', err));
// Example: creating a user
async function createUser(email, plainPassword) {
const user = new User({ email, password: plainPassword });
try {
await user.save();
console.log('User saved with hashed password');
} catch (err) {
console.error('Save failed', err);
}
}
Expected checks
- After saving a user, query the document and confirm that the
passwordfield starts with a bcrypt identifier ($2a$,$2b$or$2y$). - Use
bcrypt.compare(candidate, storedHash)to verify that a plaintext password matches the stored hash. - Introduce a deliberate error in the hook (e.g.,
throw new Error('test')) and verify that thesave()promise rejects and no document is persisted.
Recovery options (rollback)
The pre‑save hook does not persist data until save() succeeds. If the hook throws or passes an error to next(), the save operation is aborted and no document is written. To clean up a test user that was inadvertently saved with a plaintext password (e.g., if the hook was omitted), you can remove it directly:
await User.deleteOne({ email: '[contact removed]' });
In production, ensure the hook is always present before deploying changes to the user model.
Limitations and practical tips
- Hooks run on every
save()call, including updates triggered by methods likefindOneAndUpdateunless you explicitly use the{ overwrite: false }option; consider usingpre('updateOne')orpre('findOneAndUpdate')if you need hashing there. - Avoid setting bcrypt salt rounds excessively high (>15) without load testing, as authentication latency can increase noticeably.
- Never log
this.passwordinside the hook; doing so would expose plaintext credentials in application logs. - If you are using Mongoose versions prior to 6.0, the hook signature differed; ensure you pass the
nextcallback or upgrade to avoid missing calls.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.