DEV Community

Cover image for Using encrypted fields with mongodb community
Fabrice Le Coz
Fabrice Le Coz

Posted on

Using encrypted fields with mongodb community

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 hash for exact match queries
  • Range querying with ulid for 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..."
  }
}
Enter fullscreen mode Exit fullscreen mode

🧱 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;
}
Enter fullscreen mode Exit fullscreen mode

🔓 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");
}
Enter fullscreen mode Exit fullscreen mode

🛡️ 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.
}
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

🎯 Advantages of neverthrow

  • Clear separation between success and failure paths.
  • Easier composition of operations, especially using .andThen().
  • Reduces spaghetti code from deeply nested try-catch statements.

🔄 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)
  );
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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 } });
Enter fullscreen mode Exit fullscreen mode

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, and birthDate.ulid fields
  • 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 });
Enter fullscreen mode Exit fullscreen mode

🧩 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))
  };
}
Enter fullscreen mode Exit fullscreen mode

🧪 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>;
Enter fullscreen mode Exit fullscreen mode

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 keyId with 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");
}
Enter fullscreen mode Exit fullscreen mode

🧪 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>;
Enter fullscreen mode Exit fullscreen mode

🔁 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))
  };
}
Enter fullscreen mode Exit fullscreen mode

🚧 Go further

🔗 Links to official documentation

📚 Additional articles and tutorials

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