When working with sensitive user data—emails, phone numbers, birth dates—you need to secure it properly, even if you're using the open-source MongoDB Community edition (which doesn't support native field-level encryption).
This article explains how to implement robust and flexible field-level encryption in your Node.js, Bun, or Deno project, including:
- 🔐 Encrypting sensitive fields using AES-256-GCM
- 🔍 Searching encrypted data by exact match or date range
- 🔄 Supporting key rotation
- ✅ Staying compliant with regulations like GDPR
🔑 Why Field-Level Encryption?
Storing encrypted values instead of plain text ensures:
- Data remains protected even if the database is leaked
- Exposure is limited if app serialization or logs are compromised
- Fine-grained control over access and auditability
✅ GDPR Considerations
Encrypting personal data is highly encouraged by GDPR (Article 32). This strategy helps you:
- Reduce risk of data breaches
- Comply with the principle of data minimization
- Demonstrate responsibility during audits
But encryption alone doesn't exempt you from other obligations (e.g. access, erasure, data limitation). Use it as part of a broader data protection strategy.
🔐 Encryption Algorithm: AES-256-GCM
Each encrypted field is represented as a cipherValue, which is a base64 encoding of a binary buffer made of three parts:
- 12 bytes of randomly generated Initialization Vector (IV)
- 16 bytes of authentication tag (GCM integrity check)
- The actual encrypted content (ciphertext)
This format — [IV][TAG][CIPHERTEXT] — ensures deterministic and secure parsing during decryption, regardless of the encrypted field size.
We use AES-256-GCM which provides:
- Confidentiality (via symmetric encryption)
- Integrity (via built-in authentication tag)
- Unique IV per field (generated randomly)
Encrypted data is stored as base64-encoded cipherValue, optionally accompanied by a SHA-256 hash for search and a ulid for date indexing.
📦 Example: Encrypted Document Structure
We use a consistent object structure per sensitive field. This makes it easy to manage, validate, and decrypt data consistently, while also allowing:
- Indexing with
hashfor exact match queries - Range querying with
ulidfor dates - Audit and compliance support via metadata like
keyId
This field-level encapsulation ensures clarity and safety across all access and storage layers.
{
"firstname": "Alice",
"lastname": "Durand",
"email": {
"cipherValue": "...",
"hash": "..."
},
"phone": {
"cipherValue": "...",
"hash": "..."
},
"birthDate": {
"cipherValue": "...",
"ulid": "01HY..."
}
}
🧱 Generic Field Encryption Function
function encryptField(value: string): {
cipherValue: string;
hash: string;
ulid?: string;
} {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY, iv);
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
const payload = Buffer.concat([iv, tag, encrypted]);
const result = {
cipherValue: payload.toString("base64"),
hash: crypto.createHash("sha256").update(value).digest("hex")
};
const maybeDate = Date.parse(value);
if (!isNaN(maybeDate)) {
result.ulid = ulid(maybeDate);
}
return result;
}
🔓 Decrypting Encrypted Values
function decryptField(cipherValue: string): string {
const buffer = Buffer.from(cipherValue, "base64");
const iv = buffer.subarray(0, 12);
const tag = buffer.subarray(12, 28);
const ciphertext = buffer.subarray(28);
const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString("utf8");
}
🛡️ Error Handling
Proper error handling is crucial when encrypting or decrypting sensitive data to avoid accidental data leaks or unexpected service interruptions.
📌 Using Try-Catch
The classic method for handling errors in JavaScript/TypeScript involves using a try-catch block around sensitive operations:
try {
const decryptedEmail = decryptField(user.email.cipherValue);
console.log("Decrypted email:", decryptedEmail);
} catch (error) {
console.error("Error during email decryption:", error);
// Here, you can add additional logic such as logging, notifications, etc.
}
This approach is simple, straightforward, and effective for one-off cases. However, it can quickly become verbose and difficult to maintain in complex applications or when errors need centralized handling.
🚀 Functional Approach with neverthrow
For more explicit and functional error handling, the [neverthrow](https://github.com/supermacro/neverthrow) library is recommended. It uses the Result paradigm to clearly and predictably handle success and failure cases.
Here's an example implementation using neverthrow:
import { Result, err, ok } from "neverthrow";
function decryptFieldSafe(cipherValue: string): Result<string, Error> {
try {
const decryptedValue = decryptField(cipherValue);
return ok(decryptedValue);
} catch (error) {
return err(new Error(`Decryption failed: ${error}`));
}
}
// Usage example
const result = decryptFieldSafe(user.email.cipherValue);
result.match(
(email) => console.log("Decrypted email:", email),
(error) => console.error(error.message)
);
🎯 Advantages of neverthrow
- Clear separation between success and failure paths.
- Easier composition of operations, especially using
.andThen(). - Reduces spaghetti code from deeply nested
try-catchstatements.
🔄 Chaining Operations
neverthrow also supports elegant chaining of sensitive operations, which is especially useful when multiple decryption or validation steps need to occur sequentially:
decryptFieldSafe(user.email.cipherValue)
.andThen((email) => validateEmailFormat(email))
.match(
(validatedEmail) => console.log("Validated email:", validatedEmail),
(error) => console.error("Validation error:", error.message)
);
This approach significantly enhances code readability and maintainability by reducing excessive nesting.
Choose the most suitable approach for your project based on the complexity of your business logic and your preferences regarding error handling.
🔎 Search functionality
🔎 Exact Match Search with SHA-256 Hash
To search an encrypted field (like email), store a SHA-256 hash:
const hash = crypto.createHash("sha256").update(email).digest("hex");
db.users.find({ "email.hash": hash });
This enables fast indexed lookups while keeping the actual email encrypted.
📅 Date Range Search with ULID
ULIDs are time-sortable and can be generated from timestamps. They're useful for encrypted date fields:
const fromUlid = ulid(fromDate.getTime());
const toUlid = ulid(toDate.getTime());
db.users.find({ "birthDate.ulid": { $gte: fromUlid, $lte: toUlid } });
This avoids exposing birth dates while allowing range filters.
🛠️ Practical recommendations
- Use an encryption key from a secret manager (Vault, KMS, etc.)
- Never store sensitive data without encryption
- Add an index to the
email.hash,telephone.hash, andbirthDate.ulidfields - Store only the date of birth in encrypted form
indexes creation example:
db.users.createIndex({ "email.hash": 1 });
db.users.createIndex({ "phone.hash": 1 });
db.users.createIndex({ "birthDate.ulid": 1 });
🧩 Abstracting Encryption Logic (Functional Style)
Instead of repeating encryption logic everywhere, use two transformation functions:
-
encryptUserForStorage(user: PlainUser)→ Mongo document ready to insert -
decryptUserFromDocument(doc: MongoUser)→ Object usable in the application
This centralizes sensitive logic and prevents errors.
function encryptUserForStorage(user: PlainUser): MongoUser {
return {
firstname: user.firstname,
lastname: user.lastname,
email: encryptField(user.email),
telephone: encryptField(user.telephone),
birthDate: (() => {
const enc = encryptField(user.birthDate.toISOString());
return {
cipherValue: enc.cipherValue,
ulid: enc.ulid!
};
})()
};
}
function decryptUserFromDocument(doc: MongoUser): PlainUser {
return {
firstname: doc.firstname,
lastname: doc.lastname,
email: decryptField(doc.email.cipherValue),
telephone: decryptField(doc.telephone.cipherValue),
birthDate: new Date(decryptField(doc.birthDate.cipherValue))
};
}
🧪 Validating with Zod (v4)
You can also use Zod v4 to validate and type your objects. For example, you can define:
import { z } from "zod";
const PlainUserSchema = z.object({
firstname: z.string(),
lastname: z.string(),
email: z.string().email(),
telephone: z.string(),
birthDate: z.coerce.date()
});
const EncryptedFieldSchema = z.object({
cipherValue: z.string(),
hash: z.string(),
ulid: z.string().optional()
});
const MongoUserSchema = z.object({
firstname: z.string(),
lastname: z.string(),
email: EncryptedFieldSchema,
telephone: EncryptedFieldSchema,
birthDate: z.object({
cipherValue: z.string(),
ulid: z.string()
})
});
type PlainUser = z.infer<typeof PlainUserSchema>;
type MongoUser = z.infer<typeof MongoUserSchema>;
This ensures your data is consistent on both input and output, and allows you to get the correct TypeScript types effortlessly. typecast both formats.
🔄 Key Rotation
Rotating encryption keys periodically is a best practice that minimizes the blast radius in case a key is ever leaked or compromised.
🔄 Why rotate keys?
- ⛑ Limit damage in case of key exposure
- 🔐 Comply with security standards (e.g. ISO 27001, NIS2)
- 🧯 Ensure long-term cryptographic hygiene
⚠️ Risks without rotation
- If a key is exposed, all data encrypted from the beginning is compromised.
- Prolonged reuse of the same key can be vulnerable to pattern analysis attacks.
🛠️ How to implement it
- Store the
keyIdwith each encrypted field - Fetch the correct key dynamically during decryption
- Re-encrypt documents in background jobs using the new key
🧠 Example: Encrypt & Decrypt with keyId
function encryptField(value: string, keyId: string): {
cipherValue: string;
hash: string;
keyId: string;
ulid?: string;
} {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", getKeyFromVault(keyId), iv);
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
const payload = Buffer.concat([iv, tag, encrypted]);
const result = {
cipherValue: payload.toString("base64"),
hash: crypto.createHash("sha256").update(value).digest("hex"),
keyId
};
const maybeDate = Date.parse(value);
if (!isNaN(maybeDate)) result.ulid = ulid(maybeDate);
return result;
}
function decryptFieldWithKeyId(cipherValue: string, keyId: string): string {
const buffer = Buffer.from(cipherValue, "base64");
const iv = buffer.subarray(0, 12);
const tag = buffer.subarray(12, 28);
const ciphertext = buffer.subarray(28);
const decipher = crypto.createDecipheriv("aes-256-gcm", getKeyFromVault(keyId), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
}
🧪 Zod Types for Key-Aware Encrypted Fields
const EncryptedFieldWithKeySchema = z.object({
cipherValue: z.string(),
hash: z.string(),
keyId: z.string(),
ulid: z.string().optional()
});
type EncryptedFieldWithKey = z.infer<typeof EncryptedFieldWithKeySchema>;
const MongoUserWithKeySchema = z.object({
firstname: z.string(),
lastname: z.string(),
email: EncryptedFieldWithKeySchema,
telephone: EncryptedFieldWithKeySchema,
birthDate: z.object({
cipherValue: z.string(),
ulid: z.string(),
keyId: z.string()
})
});
type MongoUserWithKeyId = z.infer<typeof MongoUserWithKeySchema>;
🔁 Decrypting User with Versioned Keys
function decryptUserFromDocumentWithKey(doc: MongoUserWithKeyId): PlainUser {
return {
firstname: doc.firstname,
lastname: doc.lastname,
email: decryptFieldWithKeyId(doc.email.cipherValue, doc.email.keyId),
telephone: decryptFieldWithKeyId(doc.telephone.cipherValue, doc.telephone.keyId),
birthDate: new Date(decryptFieldWithKeyId(doc.birthDate.cipherValue, doc.birthDate.keyId))
};
}
🚧 Go further
🔗 Links to official documentation
- MongoDB Encryption at Rest (Community Edition)
- Node.js Crypto Documentation
- AES-256-GCM Explained (MDN)
- OWASP Cryptographic Storage Cheat Sheet
📚 Additional articles and tutorials
- Understanding ULID: Universally Unique Lexicographically Sortable Identifier
- Field-Level Encryption Guide for MongoDB (Official Enterprise version)
- Guide to GDPR Compliance for Developers
- neverthrow Library Documentation and Examples
🔐 Best practices in key management
⚖️ Conclusion
You now have a fully encrypted document model that is future-proof, auditable, and can evolve securely as key management policies change.
This encryption system is compatible with MongoDB Community and works with Node.js, Bun, or Deno. It allows you to combine security, compliance, and search functionality without requiring an Enterprise version.
Happy Development & Dockerizing!
Stay aware and curious!
Top comments (0)