Mongoose encryption plugin for MongoDB providing field-level AES-256-GCM encryption-at-rest with built-in tamper detection.
Secure sensitive fields such as passwords, PII, tokens, and secrets while keeping your application logic unchanged.
npm install mongoose-aes-encryptionconst mongoose = require('mongoose');
const createAESPlugin = require('mongoose-aes-encryption');
const plugin = createAESPlugin({ key: process.env.ENCRYPTION_KEY });
const userSchema = new mongoose.Schema({
token: { type: String, encrypted: true },
pin: { type: Number, encrypted: true }
});
userSchema.plugin(plugin);MongoDB stores only ciphertext — your application reads and writes plain values.
Already have an existing MongoDB with plaintext data or want to upgrade from another encryption plugin? See the migration section.
✅ Field-level encryption for Mongoose schemas
✅ Transparent encryption on save, decryption on read
✅ AES-256-GCM authenticated encryption
✅ Tamper detection for encrypted values
✅ Works with nested sub-documents, sub-schemas, and arrays
✅ Zero production dependencies — uses Node.js built-in crypto module only
❌ Not full-database encryption
❌ Not a replacement for MongoDB Atlas encryption at rest
Suppose you have the following Mongoose schema with sensitive fields:
const schema = new mongoose.Schema({
username: { type: String },
email: { type: String },
salary: { type: Number },
phoneNumbers: { type: [String] }
});To encrypt email, salary, and phoneNumbers at rest using AES-GCM, add two lines of setup and one flag per field:
const createAESPlugin = require('mongoose-aes-encryption');
const plugin = createAESPlugin({ key: process.env.ENCRYPTION_KEY });
const schema = new mongoose.Schema({
username: { type: String },
email: { type: String, encrypted: true },
salary: { type: Number, encrypted: true },
phoneNumbers: { type: [String], encrypted: true }
});
schema.plugin(plugin);That's it — the rest of your code is unchanged:
const User = mongoose.model('User', schema);
const user = new User({ username: 'alice', email: 'alice@example.com', salary: 75000, phoneNumbers: ['+1-555-0100', '+1-555-0101'] });
await user.save();
// MongoDB stores:
// { username: 'alice', email: '<iv|ciphertext|authTag>', salary: '<iv|ciphertext|authTag>',
// phoneNumbers: ['<iv|ciphertext|authTag>', '<iv|ciphertext|authTag>'] }
const found = await User.findOne({ username: 'alice' });
// Result: found.email === 'alice@example.com' (transparently decrypted)
// Result: found.salary === 75000 (transparently decrypted)
// Result: found.phoneNumbers deep-equals ['+1-555-0100', '+1-555-0101'] (each element transparently decrypted)Encrypted fields inside inline nested objects work automatically.
const schema = new mongoose.Schema({
id: { type: String, required: true },
address: {
street: { type: String, encrypted: true },
city: { type: String }
}
});
schema.plugin(plugin);Apply the plugin to both the parent schema and the sub-schema.
const contactSchema = new mongoose.Schema({
email: { type: String, encrypted: true },
phone: { type: String }
});
contactSchema.plugin(plugin);
const employeeSchema = new mongoose.Schema({
name: { type: String, encrypted: true },
contacts: [contactSchema]
});
employeeSchema.plugin(plugin);Mongoose .lean() bypasses getters and returns the raw ciphertext stored in MongoDB. To decrypt manually, use the exported decrypt function directly:
const { decrypt } = require('mongoose-aes-encryption');
const key = process.env.ENCRYPTION_KEY;
const doc = await User.findOne({ username: 'alice' }).lean();
const email = decrypt(doc.email, { key }); // → string
const salary = parseFloat(decrypt(doc.salary, { key })); // → number
const dob = new Date(decrypt(doc.birthDate, { key })); // → Date
const mfaEnabled = decrypt(doc.mfaEnabled, { key }) === 'true'; // → booleanEncryption is done automatically when a value is assigned through the Mongoose document lifecycle (new/save() or findOne() + mutate + save()). Operations that write directly to the database — updateOne, updateMany, findOneAndUpdate, bulkWrite, and atomic operators like $inc/$push — bypass the lifecycle and require manual use of the exported encrypt function.
| Operation | Support | Notes |
|---|---|---|
new Model({ field: v }); doc.save() |
Automatic | Full getter/setter round-trip. Standard path. |
Model.create({ field: v }) |
Automatic | Equivalent to new + save(). |
doc.field = v; doc.save() (after findOne()) |
Automatic | Full getter/setter round-trip. |
.lean() query |
Manual | Getter does not fire; use decrypt(doc.field, { key }) on each ciphertext field. |
Model.findOneAndUpdate(…, { $set: { field: v } }) |
Manual | Bypasses document lifecycle; use encrypt(String(v), { key }) and pass the result as the $set value. |
Model.updateOne(…, { $set: { field: v } }) |
Manual | Same as above. |
Model.updateMany(…, { $set: { field: v } }) |
Manual | Same as above — pre-encrypt each value with encrypt() before passing to $set. |
Model.findOneAndUpdate(…, { $inc: { field: n } }) |
Manual | Cannot $inc ciphertext. Use findOne() → doc.field += n → doc.save() instead. |
Model.findOneAndUpdate(…, { $push: { field: v } }) |
Manual | Cannot $push plaintext into an encrypted array. Use findOne() → doc.arr.push(v) → doc.save(), or pre-encrypt v with encrypt(String(v), { key }) and pass to $push. |
Model.bulkWrite() with updateOne/updateMany ops |
Manual | Same as updateOne/updateMany — pre-encrypt each value with encrypt() before building the bulk operations. |
Example — manual $set with pre-encryption:
const { encrypt } = require('mongoose-aes-encryption');
const key = process.env.ENCRYPTION_KEY;
const cipher = encrypt(String(newPrice), { key });
await Product.updateOne({ id: 'p-1' }, { $set: { price: cipher } });Example — manual increment workaround:
const doc = await Product.findOne({ id: 'p-1' });
doc.stock += 1;
await doc.save();Creates and returns a Mongoose plugin function that encrypts and decrypts schema fields. Call this once — before defining any schema that uses encrypted fields — and apply the returned plugin to each schema with schema.plugin().
Parameters:
options(Object): Configuration object.options.key(string): 64-character hex string (32 bytes). Required.options.algorithm(string, optional): Encryption algorithm.'aes-256-gcm'(default) or'aes-256-cbc'.
Returns: Function — Mongoose plugin function, ready to pass to schema.plugin().
Example:
const mongoose = require('mongoose');
const createAESPlugin = require('mongoose-aes-encryption');
const plugin = createAESPlugin({ key: process.env.ENCRYPTION_KEY });
const schema = new mongoose.Schema({
name: { type: String },
email: { type: String, encrypted: true },
birthDate: { type: Date, encrypted: true },
salary: { type: Number, encrypted: true },
mfaEnabled: { type: Boolean, encrypted: true }
});
schema.plugin(plugin);Encrypts a plaintext string and returns the ciphertext in wire format (iv|ciphertext|authTag for GCM, iv|ciphertext for CBC).
Parameters:
value(string): Plaintext to encrypt. Passnullorundefinedto getnullback unchanged.options(Object):options.key(string): 64-character hex key. Required.options.algorithm(string, optional):'aes-256-gcm'(default) or'aes-256-cbc'.
Returns: string — encrypted ciphertext, or null/undefined if value was nullish.
Example:
const { encrypt } = require('mongoose-aes-encryption');
const key = process.env.ENCRYPTION_KEY;
// Pre-encrypt before a $set that bypasses Mongoose middleware
const cipher = encrypt(String(newPrice), { key });
await Product.updateOne({ id: 'p-1' }, { $set: { price: cipher } });Decrypts a ciphertext string previously produced by encrypt and returns the plaintext.
Parameters:
value(string): Ciphertext in wire format. Passnullorundefinedto getnullback unchanged.options(Object):options.key(string): 64-character hex key. Required.
Returns: string — decrypted plaintext, or null/undefined if value was nullish.
Example:
const { decrypt } = require('mongoose-aes-encryption');
const key = process.env.ENCRYPTION_KEY;
// Manually decrypt fields from a lean() query
const doc = await Product.findOne({ id: 'p-1' }).lean();
const price = parseFloat(decrypt(doc.price, { key }));-
Generate a 32-byte encryption key:
openssl rand -hex 32
-
Store the key securely — an environment variable or a secrets manager. Never hardcode it.
export ENCRYPTION_KEY=<your-64-char-hex-key>
-
Call
createAESPlugin()once, before any schema that uses encrypted fields is defined:const createAESPlugin = require('mongoose-aes-encryption'); const plugin = createAESPlugin({ key: process.env.ENCRYPTION_KEY });
-
Apply the plugin to each schema and mark sensitive fields with
encrypted: true:const schema = new mongoose.Schema({ name: { type: String }, email: { type: String, encrypted: true } }); schema.plugin(plugin);
By default, mongoose-aes-encryption uses AES-256-GCM, an authenticated encryption mode. Every encrypted value is stored in MongoDB as a pipe-delimited string:
iv|ciphertext|authTag
The authTag is a cryptographic MAC computed over the ciphertext. On every read the authentication tag is verified before decryption. If the stored value has been modified in any way — bit-flip, truncation, or wholesale substitution — the tag check fails and decryption throws immediately. Corrupted or tampered ciphertext can never be silently read back as incorrect plaintext.
AES-256-CBC (available as algorithm: 'aes-256-cbc' for backwards compatibility) uses the wire format iv|ciphertext and provides no tamper detection.
.lean() results bypass Mongoose getters entirely. The raw iv|ciphertext|authTag string is returned as-is. If your application uses lean queries on collections that contain encrypted fields, treat those fields as opaque ciphertext and decrypt them explicitly using the exported decrypt function — see Lean queries and Update method compatibility.
null fields are stored as null in MongoDB without encryption. Do not rely on null values being confidential.
mongoose-aes-encryption-migrate is a companion CLI and programmatic tool for migrating existing MongoDB collections to mongoose-aes-encryption safely and idempotently. It processes documents in configurable batches, supports a --dry-run mode, and skips documents that are already encrypted so it can be re-run without side effects.
Supported migration sources:
| Source | Package |
|---|---|
| Plaintext (no prior encryption) | — |
| Field-level CBC encryption | mongoose-field-encryption |
| Document-level CBC + HMAC | mongoose-encryption |
npx mongoose-aes-encryption-migrate --source plaintext --model User
npx mongoose-aes-encryption-migrate --source mongoose-field-encryption --model User
npx mongoose-aes-encryption-migrate --source mongoose-encryption --model Usermongoose-field-encryption |
mongoose-encryption |
mongoose-aes-encryption |
|
|---|---|---|---|
| Maintenance status | Active | Last release Nov 2021 | Active |
| Default algorithm | AES-256-CBC | AES-256-CBC | AES-256-GCM |
| Tamper detection | No | Via separate HMAC-SHA-512 | GCM auth tag (built-in) |
| Encryption granularity | Per field | Whole document (_ct blob) |
Per field |
| Supported field types | String, Number, Date, Boolean | All (JSON-serialised into blob) | String, Number, Date, Boolean, arrays, nested docs |
| Schema pollution | Yes — __enc_* marker fields |
Yes — _ct, _ac fields |
No |
lean() decrypt helper |
No | No | Yes — exported decrypt() |
| Migration tool available | No | No | Yes — mongoose-aes-encryption-migrate |