Field-level AES-GCM encryption for Mongoose with mongoose-aes-encryption plugin

Storing sensitive fields like email addresses, salaries, tokens, or PII in MongoDB comes with an obvious risk if the database is compromised, plain-text values are exposed immediately. This article shows how to solve this with a simple Mongoose plugin.

mongoose-aes-encryption provides transparent field-level AES-256-GCM encryption-at-rest — your application code reads and writes plain values as usual, while only ciphertext ever touches the database.

The problem

Consider a typical user schema with sensitive data:

const schema = new mongoose.Schema({
    username:     { type: String },
    email:        { type: String },
    salary:       { type: Number },
    phoneNumbers: { type: [String] }
});

Without encryption, MongoDB stores every value in plain text. Anyone with read access to the database — a stolen backup, a misconfigured replica, a rogue admin — can read all of it instantly.

Setup of Mongoose AES-GCM encryption

Install the package:

npm install mongoose-aes-encryption

Generate a 32-byte key and store it securely (environment variable, secrets manager — never hardcode it):

export ENCRYPTION_KEY=$(openssl rand -hex 32)

Create the plugin once at application startup:

const createAESPlugin = require('mongoose-aes-encryption');
const plugin = createAESPlugin({ key: process.env.ENCRYPTION_KEY });

Basic usage

Add encrypted: true to any field and apply the plugin to the schema — that’s it:

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);

const User = mongoose.model('User', schema);

The rest of your application code remains completely unchanged:

// Write plain values — mongoose-aes-encryption encrypts transparently on save
const user = new User({ username: 'alice', email: 'alice@example.com', salary: 75000 });
await user.save();
// MongoDB stores: { email: '<iv|ciphertext|authTag>', salary: '<iv|ciphertext|authTag>', ... }

// Read plain values — decrypted transparently on load
const found = await User.findOne({ username: 'alice' });
console.log(found.email);   // 'alice@example.com'
console.log(found.salary);  // 75000

Supported field types

All common Mongoose scalar types and arrays are supported:

  • StringNumberDateBoolean
  • [String][Number] arrays
  • Inline nested sub-documents
  • Separate sub-schemas (apply the plugin to both schemas)

Lean queries and manual operations

Mongoose .lean() bypasses getters and returns raw ciphertext. Decrypt manually using the exported decrypt function:

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 });
const salary = parseFloat(decrypt(doc.salary, { key }));

Similarly, operations that bypass the Mongoose document lifecycle (updateOnefindOneAndUpdate$set, etc.) require pre-encrypting values manually using the exported encrypt function before passing them to the query.

Conclusion

mongoose-aes-encryption delivers field-level AES-256-GCM encryption with tamper detection for Mongoose schemas in two lines of setup and one flag per field. It has zero production dependencies — the Node.js built-in crypto module does all the work. Existing application logic requires no changes for standard create/read/save operations.

Happy coding 🙂

Useful links