How to use MongoDB Client-Side Field Level Encryption (CSFLE) with Node.js
Joe Karlsson12 min read • Published Jan 10, 2022 • Updated Sep 23, 2022
Rate this tutorial
Have you ever had to develop an application that stored sensitive data,
like credit card numbers or social security numbers? This is a super
common use case for databases, and it can be a pain to save this data is
secure way. Luckily for us there are some incredible security features
that come packaged with MongoDB. For example, you should know that with
MongoDB, you can take advantage of:
- Network and user-based rules, which allows administrators to grant and restrict collection-level permissions for users.
- Encryption of your data at rest, which encrypts the database files on disk.
- Transport Encryption using TLS/SSL which encrypts data over the network.
- And now, you can even have client-side encryption, known as client-side field level encryption (CSFLE).
The following diagram is a list of MongoDB security features offered and
the potential security vulnerabilities that they address:
Client-side Field Level Encryption allows the engineers to specify the
fields of a document that should be kept encrypted. Sensitive data is
transparently encrypted/decrypted by the client and only communicated to
and from the server in encrypted form. This mechanism keeps the
specified data fields secure in encrypted form on both the server and
the network. While all clients have access to the non-sensitive data
fields, only appropriately-configured CSFLE clients are able to read and
write the sensitive data fields.
In this post, we will design a Node.js client that could be used to
safely store select fields as part of a medical application.
There are a few requirements that must be met prior to attempting to use
Client-Side Field Level Encryption (CSFLE) with the Node.js driver.
- MongoDB Atlas 4.2+ or MongoDB Server 4.2 Enterprise
- MongoDB Node driver 3.6.2+
This tutorial will focus on automatic encryption. While this tutorial
will use MongoDB Atlas, you're
going to need to be using version 4.2 or newer for MongoDB Atlas or
MongoDB Enterprise Edition. You will not be able to use automatic field
level encryption with MongoDB Community Edition.
The assumption is that you're familiar with developing Node.js
applications that use MongoDB. If you want a refresher, take a look at
the quick start
series
that we published on the topic.
Because of the libmongocrypt and mongocryptd requirements, it's
worth reviewing how to install and configure them. We'll be exploring
installation on macOS, but refer to the documentation for
libmongocrypt and
mongocryptd
for your particular operating system.
libmongocrypt is required for automatic field level
encryption,
as it is the component that is responsible for performing the encryption
or decryption of the data on the client with the MongoDB 4.2-compatible
Node drivers. Now, there are currently a few solutions for installing
the libmongocrypt library on macOS. However, the easiest is with
Homebrew. If you've got Homebrew installed, you can
install libmongocrypt with the following command:
1 brew install mongodb/brew/libmongocrypt
I ran into an issue with libmongocrypt when I tried to run my code,
because libmongocrypt was trying to statically link against
libmongocrypt instead of dynamically linking. I have submitted an issue
to the team to fix this issue, but to fix it, I had to run:
1 export BUILD_TYPE=dynamic
mongocryptd is required for automatic field level
encryption
and is included as a component in the MongoDB Enterprise
Server
package. mongocryptd is only responsible for supporting automatic
client-side field level encryption and does not perform encryption or
decryption.
You'll want to consult the
documentation
on how to obtain the mongocryptd binary as each operating system has
different steps.
For macOS, you'll want to download MongoDB Enterprise Edition from the
MongoDB Download
Center.
You can refer to the Enterprise Edition installation
instructions
for macOS to install, but the gist of the installation involves
extracting the TAR file and moving the files to the appropriate
directory.
By this point, all the appropriate components for client-side field
level encryption should be installed or available. Make sure that you
are running MongoDB enterprise on your client while using CSFLE, even if
you are saving your data to Atlas.
Let's start by setting up all the files and dependencies we will need.
In a new directory, create the following files, running the following
command:
1 touch clients.js helpers.js make-data-key.js
Be sure to initialize a new NPM project, since we will be using several
NPM dependencies.
1 npm init --yes
And let's just go ahead and install all the packages that we will be
using now.
1 npm install -S mongodb mongodb-client-encryption node-gyp
Note: The complete codebase for this project can be found here:
https://github.com/JoeKarlsson/client-side-field-level-encryption-csfle-mongodb-node-demo
MongoDB Client-Side Field Level Encryption (CSFLE) uses an encryption
strategy called envelope encryption in which keys used to
encrypt/decrypt data (called data encryption keys) are encrypted with
another key (called the master key). The following diagram shows how the
master key is created and stored:
Warning
The Local Key Provider is not suitable for production.
The Local Key Provider is an insecure method of storage and is therefore
not recommended if you plan to use CSFLE in production. Instead, you
should configure a master key in a Key Management
System
(KMS) which stores and decrypts your data encryption keys remotely.
To learn how to use a KMS in your CSFLE implementation, read the
Client-Side Field Level Encryption: Use a KMS to Store the Master
Key
guide.
1 // clients.js 2 3 const fs = require("fs") 4 const mongodb = require("mongodb") 5 const { ClientEncryption } = require("mongodb-client-encryption") 6 const { MongoClient, Binary } = mongodb 7 8 module.exports = { 9 readMasterKey: function (path = "./master-key.txt") { 10 return fs.readFileSync(path) 11 }, 12 CsfleHelper: class { 13 constructor({ 14 kmsProviders = null, 15 keyAltNames = "demo-data-key", 16 keyDB = "encryption", 17 keyColl = "__keyVault", 18 schema = null, 19 connectionString = "mongodb://localhost:27017", 20 mongocryptdBypassSpawn = false, 21 mongocryptdSpawnPath = "mongocryptd" 22 } = {}) { 23 if (kmsProviders === null) { 24 throw new Error("kmsProviders is required") 25 } 26 this.kmsProviders = kmsProviders 27 this.keyAltNames = keyAltNames 28 this.keyDB = keyDB 29 this.keyColl = keyColl 30 this.keyVaultNamespace = `${keyDB}.${keyColl}` 31 this.schema = schema 32 this.connectionString = connectionString 33 this.mongocryptdBypassSpawn = mongocryptdBypassSpawn 34 this.mongocryptdSpawnPath = mongocryptdSpawnPath 35 this.regularClient = null 36 this.csfleClient = null 37 } 38 39 /** 40 * In the guide, https://mongodb.prakticum-team.ru/proxy/docs.mongodb.com/ecosystem/use-cases/client-side-field-level-encryption-guide/, 41 * we create the data key and then show that it is created by 42 * retreiving it using a findOne query. Here, in implementation, we only 43 * create the key if it doesn't already exist, ensuring we only have one 44 * local data key. 45 * 46 * @param {MongoClient} client 47 */ 48 async findOrCreateDataKey(client) { 49 const encryption = new ClientEncryption(client, { 50 keyVaultNamespace: this.keyVaultNamespace, 51 kmsProviders: this.kmsProviders 52 }) 53 54 await this.ensureUniqueIndexOnKeyVault(client) 55 56 let dataKey = await client 57 .db(this.keyDB) 58 .collection(this.keyColl) 59 .findOne({ keyAltNames: { $in: [this.keyAltNames] } }) 60 61 if (dataKey === null) { 62 dataKey = await encryption.createDataKey("local", { 63 keyAltNames: [this.keyAltNames] 64 }) 65 return dataKey.toString("base64") 66 } 67 68 return dataKey["_id"].toString("base64") 69 } 70 }
The following script generates a 96-byte, locally-managed master key and
saves it to a file called master-key.txt in the directory from which the
script is executed, as well as saving it to our impromptu key management
system in Atlas.
1 // make-data-key.js 2 3 const { readMasterKey, CsfleHelper } = require("./helpers"); 4 const { connectionString } = require("./config"); 5 6 async function main() { 7 const localMasterKey = readMasterKey() 8 9 const csfleHelper = new CsfleHelper({ 10 kmsProviders: { 11 local: { 12 key: localMasterKey 13 } 14 }, 15 connectionString: "PASTE YOUR MONGODB ATLAS URI HERE" 16 }) 17 18 const client = await csfleHelper.getRegularClient() 19 20 const dataKey = await csfleHelper.findOrCreateDataKey(client) 21 console.log("Base64 data key. Copy and paste this into clients.js\t", dataKey) 22 23 client.close() 24 } 25 26 main().catch(console.dir)
After saving this code, run the following to generate and save our keys.
1 node make-data-key.js
And you should get this output in the terminal. Be sure to save this
key, as we will be using it in our next step.
It's also a good idea to check in to make sure that this data has been
saved correctly. Go to your clusters in Atlas, and navigate to your
collections. You should see a new key saved in the
encryption.__keyVault collection.
Your key should be shaped like this:
1 { 2 "_id": "UUID('27a51d69-809f-4cb9-ae15-d63f7eab1585')", 3 "keyAltNames": ["demo-data-key"], 4 "keyMaterial": "Binary('oJ6lEzjIEskH...', 0)", 5 "creationDate": "2020-11-05T23:32:26.466+00:00", 6 "updateDate": "2020-11-05T23:32:26.466+00:00", 7 "status": "0", 8 "masterKey": { 9 "provider": "local" 10 } 11 }
With the data key created, we're at a point in time where we need to
figure out what fields should be encrypted in a document and what fields
should be left as plain text. The easiest way to do this is with a
schema map.
A schema map for encryption is extended JSON and can be added directly
to the Go source code or loaded from an external file. From a
maintenance perspective, loading from an external file is easier to
maintain.
The following table illustrates the data model of the Medical Care
Management System.
Field type | Encryption Algorithm | BSON Type |
---|---|---|
Name | Non-Encrypted | String |
SSN | Deterministic | Int |
Blood Type | Random | String |
Medical Records | Random | Array |
Insurance: Policy Number | Deterministic | Int (embedded inside insurance object) |
Insurance: Provider | Non-Encrypted | String (embedded inside insurance object) |
Let's add a function to our csfleHelper method in helper.js file so
our application knows which fields need to be encrypted and decrypted.
1 if (dataKey === null) { 2 throw new Error( 3 "dataKey is a required argument. Ensure you've defined it in clients.js" 4 ) 5 } 6 return { 7 "medicalRecords.patients": { 8 bsonType: "object", 9 // specify the encryptMetadata key at the root level of the JSON Schema. 10 // As a result, all encrypted fields defined in the properties field of the 11 // schema will inherit this encryption key unless specifically overwritten. 12 encryptMetadata: { 13 keyId: [new Binary(Buffer.from(dataKey, "base64"), 4)] 14 }, 15 properties: { 16 insurance: { 17 bsonType: "object", 18 properties: { 19 // The insurance.policyNumber field is embedded inside the insurance 20 // field and represents the patient's policy number. 21 // This policy number is a distinct and sensitive field. 22 policyNumber: { 23 encrypt: { 24 bsonType: "int", 25 algorithm: "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic" 26 } 27 } 28 } 29 }, 30 // The medicalRecords field is an array that contains a set of medical record documents. 31 // Each medical record document represents a separate visit and specifies information 32 // about the patient at that that time, such as their blood pressure, weight, and heart rate. 33 // This field is sensitive and should be encrypted. 34 medicalRecords: { 35 encrypt: { 36 bsonType: "array", 37 algorithm: "AEAD_AES_256_CBC_HMAC_SHA_512-Random" 38 } 39 }, 40 // The bloodType field represents the patient's blood type. 41 // This field is sensitive and should be encrypted. 42 bloodType: { 43 encrypt: { 44 bsonType: "string", 45 algorithm: "AEAD_AES_256_CBC_HMAC_SHA_512-Random" 46 } 47 }, 48 // The ssn field represents the patient's 49 // social security number. This field is 50 // sensitive and should be encrypted. 51 ssn: { 52 encrypt: { 53 bsonType: "int", 54 algorithm: "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic" 55 } 56 } 57 } 58 }
Alright, so now we have the JSON Schema and encryption keys necessary to
create a CSFLE-enabled MongoDB client. Let's recap how our client will
work. Our CSFLE-enabled MongoDB client will query our encrypted data,
and the mongocryptd process will be automatically started by
default. mongocryptd handles the following responsibilities:
- Validates the encryption instructions defined in the JSON Schema and flags the referenced fields for encryption in read and write operations.
- Prevents unsupported operations from being executed on encrypted fields.
To create the CSFLE-enabled client, we need to instantiate a standard
MongoDB client object with the additional automatic encryption settings
with the following code snippet:
1 async getCsfleEnabledClient(schemaMap = null) { 2 if (schemaMap === null) { 3 throw new Error( 4 "schemaMap is a required argument. Build it using the CsfleHelper.createJsonSchemaMap method" 5 ) 6 } 7 const client = new MongoClient(this.connectionString, { 8 useNewUrlParser: true, 9 useUnifiedTopology: true, 10 monitorCommands: true, 11 autoEncryption: { 12 // The key vault collection contains the data key that the client uses to encrypt and decrypt fields. 13 keyVaultNamespace: this.keyVaultNamespace, 14 // The client expects a key management system to store and provide the application's master encryption key. 15 // For now, we will use a local master key, so they use the local KMS provider. 16 kmsProviders: this.kmsProviders, 17 // The JSON Schema that we have defined doesn't explicitly specify the collection to which it applies. 18 // To assign the schema, they map it to the medicalRecords.patients collection namespace 19 schemaMap 20 } 21 }) 22 return await client.connect() 23 }
If the connection was successful, the client is returned.
We now have a CSFLE-enabled client and we can test that the client can
perform queries that meet our security requirements.
The following diagram shows the steps taken by the client application
and driver to perform a write of field-level encrypted data:
We need to write a function in our clients.js to create a new patient
record with the following code snippet:
Note: Clients that do not have CSFLE configured will insert unencrypted
data. We recommend using server-side schema
validation to
enforce encrypted writes for fields that should be encrypted.
1 const { readMasterKey, CsfleHelper } = require("./helpers"); 2 const { connectionString, dataKey } = require("./config"); 3 4 const localMasterKey = readMasterKey() 5 6 const csfleHelper = new CsfleHelper({ 7 // The client expects a key management system to store and provide the application's master encryption key. For now, we will use a local master key, so they use the local KMS provider. 8 kmsProviders: { 9 local: { 10 key: localMasterKey 11 } 12 }, 13 connectionString, 14 }) 15 16 async function main() { 17 let regularClient = await csfleHelper.getRegularClient() 18 let schemeMap = csfleHelper.createJsonSchemaMap(dataKey) 19 let csfleClient = await csfleHelper.getCsfleEnabledClient(schemeMap) 20 21 let exampleDocument = { 22 name: "Jon Doe", 23 ssn: 241014209, 24 bloodType: "AB+", 25 medicalRecords: [ 26 { 27 weight: 180, 28 bloodPressure: "120/80" 29 } 30 ], 31 insurance: { 32 provider: "MaestCare", 33 policyNumber: 123142 34 } 35 } 36 37 const regularClientPatientsColl = regularClient 38 .db("medicalRecords") 39 .collection("patients") 40 const csfleClientPatientsColl = csfleClient 41 .db("medicalRecords") 42 .collection("patients") 43 44 // Performs the insert operation with the csfle-enabled client 45 // We're using an update with an upsert so that subsequent runs of this script 46 // don't insert new documents 47 await csfleClientPatientsColl.updateOne( 48 { ssn: exampleDocument["ssn"] }, 49 { $set: exampleDocument }, 50 { upsert: true } 51 ) 52 53 // Performs a read using the encrypted client, querying on an encrypted field 54 const csfleFindResult = await csfleClientPatientsColl.findOne({ 55 ssn: exampleDocument["ssn"] 56 }) 57 console.log( 58 "Document retreived with csfle enabled client:\n", 59 csfleFindResult 60 ) 61 62 // Performs a read using the regular client. We must query on a field that is 63 // not encrypted. 64 // Try - query on the ssn field. What is returned? 65 const regularFindResult = await regularClientPatientsColl.findOne({ 66 name: "Jon Doe" 67 }) 68 console.log("Document retreived with regular client:\n", regularFindResult) 69 70 await regularClient.close() 71 await csfleClient.close() 72 } 73 74 main().catch(console.dir)
The following diagram shows the steps taken by the client application
and driver to query and decrypt field-level encrypted data:
We can run queries on documents with encrypted fields using standard
MongoDB driver methods. When a doctor performs a query in the Medical
Care Management System to search for a patient by their SSN, the driver
decrypts the patient's data before returning it:
1 { 2 "_id": "5d6ecdce70401f03b27448fc", 3 "name": "Jon Doe", 4 "ssn": 241014209, 5 "bloodType": "AB+", 6 "medicalRecords": [ 7 { 8 "weight": 180, 9 "bloodPressure": "120/80" 10 } 11 ], 12 "insurance": { 13 "provider": "MaestCare", 14 "policyNumber": 123142 15 } 16 }
If you attempt to query your data with a MongoDB that isn't configured
with the correct key, this is what you will see:
And you should see your data written to your MongoDB Atlas database:
If you run into any issues running your code locally, I have developed a
Docker image that you can use to help you get setup quickly or to
troubleshoot local configuration issues. You can download the code
here.
Make sure you have docker configured locally before you run the code.
You can download Docker
here.
- Change directories to the Docker directory.
1 cd docker - Build Docker image with a tag name. Within this directory, execute:
1 docker build . -t mdb-csfle-example This will build a Docker image with a tag name mdb-csfle-example. - Run the Docker image by executing:
1 docker run -tih csfle mdb-csfle-example The command above will run a Docker image with tag mdb-csfle-example and provide it with csfle as its hostname. - Once you're inside the Docker container, you can follow the below steps to run the NodeJS code example.
1 $ export MONGODB_URL="mongodb+srv://USER:PWD@EXAMPLE.mongodb.net/dbname?retryWrites=true&w=majority" 2 3 $ node ./example.js
We wanted to develop a system that securely stores sensitive medical
records for patients. We also wanted strong data access and security
guarantees that do not rely on individual users. After researching the
available options, we determined that MongoDB Client-Side Field Level
Encryption satisfies their requirements and decided to implement it in
their application. To implement CSFLE, we did the following:
1. Created a Locally-Managed Master Encryption Key
A locally-managed master key allowed us to rapidly develop the client
application without external dependencies and avoid accidentally leaking
sensitive production credentials.
2. Generated an Encrypted Data Key with the Master Key
CSFLE uses envelope encryption, so we generated a data key that encrypts
and decrypts each field and then encrypted the data key using a master
key. This allows us to store the encrypted data key in MongoDB so that
it is shared with all clients while preventing access to clients that
don't have access to the master key.
3. Created a JSON Schema
CSFLE can automatically encrypt and decrypt fields based on a provided
JSON Schema that specifies which fields to encrypt and how to encrypt
them.
4. Tested and Validated Queries with the CSFLE Client
We tested their CSFLE implementation by inserting and querying documents
with encrypted fields. We then validated that clients without CSFLE
enabled could not read the encrypted data.
In this guide, we stored the master key in your local file system. Since
your data encryption keys would be readable by anyone that gains direct
access to your master key, we strongly recommend that you use a more
secure storage location such as a Key Management System (KMS).
For more information on client-side field level encryption in MongoDB,
check out the reference docs in the server manual:
- For additional information on the MongoDB CSFLE API, see the official Node.js driver documentation
- Questions? Comments? We'd love to connect with you. Join the conversation on the MongoDB Community Forums.