Every MEAN team we consult for eventually gets the same question from a customer's security review: "Is the personal data encrypted at rest, and can your own DBAs read it?" Disk encryption and TLS answer the first half. They do not answer the second. Anyone with read access to the collection — an operator, a backup file, a compromised application server, or the cloud provider — sees plaintext.
MongoDB 8's Queryable Encryption (QE) closes that gap. Fields are encrypted by the driver before they leave your Node process, stored as ciphertext, and still remain queryable for equality and (in 8.0, now GA) range queries. The server never sees the keys. This tutorial wires QE into a MEAN app end to end: key management, schema, an Express 5 API, and what Angular 22 has to do differently (spoiler: almost nothing, and that is the point).
All versions are current as of 2026: Node.js 24 LTS, mongodb driver 6.x with mongodb-client-encryption, Mongoose 8, MongoDB 8.0 Enterprise or Atlas.
When to use QE (and when not to)
QE is not free. Encrypted fields cost storage, add metadata collections, and restrict which queries you can run. Use it for a short list of genuinely sensitive fields:
- Good candidates: national ID numbers, dates of birth, salary, diagnosis codes, account numbers, contact details under GDPR/HIPAA scope.
- Bad candidates: fields you need to aggregate on, do
$regex/ text search against, or sort by arbitrarily. Encrypted equality and range are supported; substring search,$textand most aggregation operators are not. - Also bad: your whole document. Teams that encrypt everything end up disabling QE six months later because reporting broke.
Availability note: QE requires MongoDB 8.0 Enterprise, Atlas (M10+), or Enterprise-equivalent. It is not available on the free Community server — check this before you promise it in a contract.
Step 1: keys — the part people get wrong
QE uses envelope encryption. A Customer Master Key (CMK) lives in a KMS (AWS KMS, Azure Key Vault, GCP KMS, or a local key for development). The CMK wraps Data Encryption Keys (DEKs), which are stored in a key vault collection in MongoDB and used to encrypt individual fields.
For local development you can generate a 96-byte local master key. Never ship this to production; it is a plaintext key sitting next to the data it protects.
node -e "console.log(require('crypto').randomBytes(96).toString('base64'))" > master-key.txt
Production uses a real KMS. The provider config is the only thing that changes:
// api/src/kms.js
export const kmsProviders = process.env.NODE_ENV === 'production'
? { aws: { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY } }
: { local: { key: Buffer.from(process.env.LOCAL_MASTER_KEY, 'base64') } };
export const masterKey = process.env.NODE_ENV === 'production'
? { provider: 'aws', region: process.env.AWS_REGION, key: process.env.AWS_CMK_ARN }
: undefined;
export const keyVaultNamespace = 'encryption.__keyVault';
Step 2: create a DEK per data domain
Create one DEK per logical data domain (per tenant, per regulated dataset), not one for the entire database. That gives you a crypto-shredding story: destroy the DEK and that domain's ciphertext is permanently unreadable, which is a defensible answer to "delete all my data" requests.
npm install mongodb@6 mongodb-client-encryption mongoose@8 express@5 zod
// api/scripts/create-dek.js
import { MongoClient } from 'mongodb';
import { ClientEncryption } from 'mongodb-client-encryption';
import { kmsProviders, masterKey, keyVaultNamespace } from '../src/kms.js';
const client = await MongoClient.connect(process.env.MONGODB_URI);
// The key vault needs a unique index on keyAltNames. Do this once.
const [db, coll] = keyVaultNamespace.split('.');
await client.db(db).collection(coll).createIndex(
{ keyAltNames: 1 },
{ unique: true, partialFilterExpression: { keyAltNames: { $exists: true } } }
);
const encryption = new ClientEncryption(client, { keyVaultNamespace, kmsProviders });
const keyId = await encryption.createDataKey(masterKey ? 'aws' : 'local', {
masterKey,
keyAltNames: ['patients-2026'],
});
console.log('DEK _id (base64):', keyId.toString('base64'));
await client.close();
Store the key alt name in config, not the raw keyId; look the key up by alt name at boot so key rotation does not mean a code change.
Step 3: the encrypted schema map
QE is driven by an encryptedFieldsMap, declared when you create the client. Each field states its BSON type and the query types it must support.
// api/src/encrypted-fields.js
export function buildEncryptedFieldsMap(keyId) {
return {
'app.patients': {
fields: [
{ keyId, path: 'ssn', bsonType: 'string', queries: { queryType: 'equality' } },
{ keyId, path: 'email', bsonType: 'string', queries: { queryType: 'equality' } },
{
keyId,
path: 'salary',
bsonType: 'int',
queries: { queryType: 'range', min: 0, max: 2_000_000, sparsity: 1 },
},
{ keyId, path: 'notes', bsonType: 'string' }, // encrypted, never queried
],
},
};
}
Three things to internalise:
- A field with no
queriesblock is encrypted but unqueryable — cheapest and safest. Default to this. equalitysupports$eqand$in. It does not support$regex, prefix match, or case-insensitive match.range(GA in MongoDB 8.0) supports$gt/$gte/$lt/$lteand sorting within the range. Themin,maxandsparsityvalues are a security/performance trade-off: a tighter range leaks less and performs better, but you cannot widen it later without re-encrypting the collection.
Step 4: connect Mongoose 8 through the encrypted client
Mongoose accepts driver-level autoEncryption options. The collection must be created by the encrypted client so MongoDB provisions the internal enxcol_.* metadata collections — creating it first with a plain client and adding QE later does not work.
// api/src/db.js
import mongoose from 'mongoose';
import { MongoClient } from 'mongodb';
import { kmsProviders, keyVaultNamespace } from './kms.js';
import { buildEncryptedFieldsMap } from './encrypted-fields.js';
async function lookupKeyId(uri, altName) {
const c = await MongoClient.connect(uri);
const [db, coll] = keyVaultNamespace.split('.');
const key = await c.db(db).collection(coll).findOne({ keyAltNames: altName });
await c.close();
if (!key) throw new Error(`DEK "${altName}" not found — run scripts/create-dek.js`);
return key._id;
}
export async function connectDb() {
const uri = process.env.MONGODB_URI;
const keyId = await lookupKeyId(uri, process.env.DEK_ALT_NAME ?? 'patients-2026');
await mongoose.connect(uri, {
autoEncryption: {
keyVaultNamespace,
kmsProviders,
encryptedFieldsMap: buildEncryptedFieldsMap(keyId),
extraOptions: { cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH },
},
});
// First run only: create the collection through the encrypted client.
const names = await mongoose.connection.db.listCollections({ name: 'patients' }).toArray();
if (names.length === 0) {
await mongoose.connection.db.createCollection('patients', {
encryptedFields: buildEncryptedFieldsMap(keyId)['app.patients'],
});
}
return mongoose.connection;
}
cryptSharedLibPath points at the crypt_shared library MongoDB ships for automatic encryption. Download it once and bake it into your Docker image — the alternative (mongocryptd as a spawned process) is harder to run in a container and is being phased out of most deployments.
# Dockerfile excerpt
COPY --from=cryptlib /lib/mongo_crypt_v1.so /opt/mongo/mongo_crypt_v1.so
ENV CRYPT_SHARED_LIB_PATH=/opt/mongo/mongo_crypt_v1.so
Step 5: the Express 5 route reads like ordinary Mongoose
This is the payoff. With automatic encryption configured, your application code does not encrypt or decrypt anything explicitly.
// api/src/routes/patients.js
import { Router } from 'express';
import { z } from 'zod';
import { Patient } from '../models.js';
const router = Router();
const createSchema = z.object({
name: z.string().min(1).max(120),
ssn: z.string().regex(/^\d{3}-\d{2}-\d{4}$/),
email: z.string().email(),
salary: z.number().int().min(0).max(2_000_000),
notes: z.string().max(4000).optional(),
});
// Express 5 forwards rejected promises to the error handler automatically.
router.post('/', async (req, res) => {
const data = createSchema.parse(req.body);
const patient = await Patient.create(data); // ssn/email/salary encrypted in the driver
res.status(201).json({ id: patient.id });
});
router.get('/by-email/:email', async (req, res) => {
const email = z.string().email().parse(req.params.email);
const patient = await Patient.findOne({ email }).lean(); // equality query on ciphertext
if (!patient) return res.status(404).json({ error: 'not_found' });
res.json(patient); // decrypted in the driver
});
router.get('/band', async (req, res) => {
const { min, max } = z.object({ min: z.coerce.number().int(), max: z.coerce.number().int() }).parse(req.query);
const rows = await Patient.find({ salary: { $gte: min, $lte: max } }).lean();
res.json(rows);
});
export default router;
Prove it works by connecting a plain client and looking at the raw document:
const plain = await MongoClient.connect(process.env.MONGODB_URI);
console.log(await plain.db('app').collection('patients').findOne({}));
// { _id: ..., name: 'Ada Lovelace', ssn: Binary(...), email: Binary(...), salary: Binary(...) }
name is readable; the regulated fields are Binary blobs. That screenshot is what ends the security review.
Step 6: what changes in Angular 22
Nothing on the wire — the API still returns JSON. What changes is your response shaping, and that is where most teams accidentally undo the work:
- Never send
ssnto the browser unless the screen genuinely needs it. Add a projection or a DTO mapper:Patient.findById(id).select('-ssn -notes'). - Mask server-side, not client-side.
***-**-1234should be produced in Express; a client-side mask still shipped the full value to the device and to anyone with devtools. - Turn off HTTP caching for these responses (
Cache-Control: no-store) and keep them out of Angular'sTransferStateduring SSR — hydration payloads are embedded in the HTML and end up in CDN caches and browser history.
// a signal-based resource that only ever sees masked data
readonly patient = httpResource<PatientView>(() => `/api/patients/${this.id()}`);
Operational gotchas we hit on real engagements
- Backups and restores. A
mongodumpof an encrypted collection is ciphertext plus metadata. It is worthless without the key vault and the CMK. Back up the key vault collection, and document CMK recovery — losing the CMK is permanent data loss. Test the restore. - Analytics and BI. Your BI tool connects without QE configuration, so it sees
Binary. Plan for a separate, decrypted, aggregated reporting pipeline or accept that encrypted fields are invisible to reporting. - Index behaviour. You cannot create your own index on an encrypted field; QE manages its own. Compound indexes mixing encrypted and plain fields are not allowed.
- Migrating existing data. You cannot encrypt in place. The pattern is expand/contract: create a new QE-enabled collection, dual-write, backfill through the encrypted client in batches, verify counts, cut reads over, drop the old collection. Budget a full sprint for a large collection.
- Performance. Expect equality lookups on encrypted fields to be measurably slower than on a plain index, and writes to carry extra metadata cost. Benchmark with production-shaped data before committing to a range field.
- Key rotation. Rotating the CMK rewraps the DEKs and is cheap. Rotating a DEK means re-encrypting data — treat it like a migration.
A pragmatic rollout order
- Classify fields with the client's compliance owner. Write the list down; it is a deliverable.
- Encrypt the unqueried fields first (no
queriesblock). Low risk, immediate compliance value. - Add
equalityonly where a lookup genuinely needs it. - Add
rangelast, and only after benchmarking. - Automate a restore drill that proves you can recover from the CMK plus a backup.
Queryable Encryption is one of the few MongoDB 8 features that changes what you are allowed to promise in a contract rather than just how fast your app runs. Get the key management right and the rest of the MEAN stack barely notices it is there.
Working through a compliance requirement — HIPAA, GDPR, SOC 2 — on an existing MongoDB or MEAN application? Our consultants do field classification, QE rollouts and encrypted-collection migrations as fixed-scope engagements. Get in touch and describe your data and your deadline.