Encrypting PII in a Node.js app with WorkOS Vault
Store, retrieve, update, and delete sensitive user data using Vault's full CRUD lifecycle (no cryptography expertise required).
Your database uses encryption at rest. You checked that box during the last security review.
But when an engineer runs a query to debug a support ticket, the user's email address appears in plaintext. When you export a table for analysis, it includes full phone numbers. When a misconfigured API endpoint leaks rows from the wrong tenant, their PII is immediately readable.
Encryption at rest protects against someone physically stealing your database hardware. It does nothing to protect against anyone who can issue a SQL query, which includes your engineers, your analytics pipeline, and any attacker who gets a database credential.
What you actually want is for PII to be encrypted before it reaches the database, with the keys held separately, so that even a full table export is just ciphertext. That is what this tutorial covers.
We are going to use WorkOS Vault to encrypt user PII at the application layer, tied to each user's organization so the key boundary matches your tenancy model. No cryptography knowledge required. Vault handles key management. You handle the application logic.
What we are building
The scenario is a multi-tenant SaaS app where users belong to organizations. When a user updates their profile, their PII (email address, phone number, billing address) is stored encrypted in Vault rather than in plaintext in the application database.
After this tutorial, your database will store:
- The user's ID
- Their organization ID
- The Vault object IDs that reference each encrypted field
And it will not store any plaintext PII.
At read time, the application fetches the Vault objects by ID and decrypts them on the fly. At deletion time, a single API call per field handles the GDPR right-to-erasure requirement cleanly.
By the end you will have a single vault.ts module that handles the full CRUD lifecycle for user PII. We will cover each operation individually, explain the decisions behind it, and assemble the complete module at the end.
Prerequisites:
- Node.js 18 or later
- A WorkOS account with Vault enabled
WORKOS_API_KEYset in your environment
Setup
Install the WorkOS Node SDK:
Initialize the client once and export it for use across the application:
Store WORKOS_API_KEY as a managed secret in your environment. Never commit it or hardcode it. The SDK reads it automatically if it is set as WORKOS_API_KEY, so you can also initialize new WorkOS() with no arguments and let the SDK pick it up.
Two design decisions before writing any code
Before looking at individual operations, it is worth being explicit about two decisions that shape the entire module. Both have meaningful security consequences.
Vault IDs in the database, not values. The application database stores the object IDs returned by Vault after each createObject call. The plaintext PII goes into Vault and never touches the database. This means a SQL injection, a rogue internal query, or a misconfigured export returns IDs like secret_51B0AC67C2FB4247AC5ABDDD3C701BDC, not email addresses or phone numbers.
Key context tied to organizationId. Every Vault operation takes a context parameter that determines which encryption key is used. By setting context: { organizationId: user.organizationId }, each organization's PII is encrypted with a cryptographically isolated key. A bug that leaks one tenant's Vault IDs cannot be decrypted using another tenant's key. The boundary is mathematical, not just logical. (For a deeper explanation of how this works, see Cryptographic key isolation in multi-tenant SaaS.)
With those decisions made, we can write the code.
Storing PII: createObject
Naming convention
Vault object names are unique string identifiers. For PII fields, a consistent naming convention makes lookups, auditing, and deletion predictable. A good pattern is:
So for user usr_abc123:
pii:usr_abc123:emailpii:usr_abc123:phonepii:usr_abc123:billing_address
One important note: object names are not encrypted. They are identifiers, not values. Do not put the sensitive data in the name itself. pii:usr_abc123:email is fine. pii:alice@example.com is not.
Storing a single field
createObject takes three things: a unique name, the plaintext value to encrypt, and the key context. It returns an object with an id field. That ID is what you store. The plaintext value is encrypted inside Vault and does not come back.
Storing a full user profile
A real profile update involves several fields at once. Store them in parallel with Promise.all and return all three IDs:
After calling this, your database row for the user looks something like this:
No email addresses. No phone numbers. No billing details. A SQL injection against the users table returns IDs that are useless without Vault access.
Reading PII back: readObject and describeObject
Reading a single field
readObject fetches the encrypted object, decrypts it using the key associated with its stored context, and returns the plaintext value. The decryption happens server-side in Vault. Your application receives the plaintext over a TLS connection.
Reading a full user profile
When not to call readObject
readObject decrypts the value. There are many situations where you need information about a Vault object without needing the plaintext. For those, use describeObject instead.
describeObject returns the object's id, name, and metadata (including the current versionId) without performing a decryption:
Use describeObject for:
- Checking whether a PII field exists for a user
- Reading the
versionIdbefore an update (covered in the next section) - Audit logging (when was this field last updated, how large is it)
- Any existence or metadata check that does not require the actual value
The principle: only call readObject when the application actually needs the plaintext. Every unnecessary decryption is an unnecessary moment where sensitive data exists in memory and in application logs. describeObject handles everything else.
Updating PII: updateObject and versionCheck
The basic update
updateObject replaces the encrypted value at the given ID. The key context is fixed at creation time and cannot be changed. Only the value can be updated. If you need to move an object to a different organization, delete it and recreate it under the new context.
The race condition problem
In a real application, multiple processes can write to the same object concurrently. A user submitting a profile edit from two browser tabs. An automated credential rotation process and a user update overlapping. Without a consistency lock, the last write wins silently and the earlier update is lost with no error.
versionCheck as a consistency lock
versionCheck takes the versionId from the object's current metadata. If the object has been updated since you last read that version, Vault rejects the write rather than overwriting silently.
The pattern is: read the current version with describeObject, then pass that version ID into updateObject:
Notice that we use describeObject here, not readObject. We do not need the current plaintext value. We only need the version ID from the metadata. Using describeObject avoids an unnecessary decryption.
If a concurrent update changed the object between your describeObject and updateObject calls, Vault rejects the write. Catch that error and either retry the operation with a fresh describeObject, or surface a conflict message to the user.
Deleting PII: deleteObject
Deleting a single field
After calling deleteObject, the object is immediately unavailable to all Vault API operations. Subsequent readObject or describeObject calls for that ID will fail.
Deleting a full user profile
GDPR right to erasure
Article 17 of GDPR requires that personal data be erased "without undue delay" when a user requests deletion. Vault's model maps cleanly to this requirement in two ways.
First, PII lives only in Vault. Because you are not storing plaintext in the application database, there is no risk of forgetting a column, a backup table, a denormalized analytics copy, or a queue payload. The database has IDs. The data is in Vault.
Second, deletion is a single API call per object. When a user closes their account, call deleteUserPii with their vault IDs, then null out the ID columns in your database. The erasure is complete.
Auditing before deletion
Before deleting, it is good practice to verify that you have found all Vault objects for a given user. If you follow the pii:{userId}:{field} naming convention, you can list all objects whose names start with that prefix using the search parameter on listObjects:
This returns all objects whose names match the search string. Compare the returned names against the fields you expect (email, phone, billing_address) to confirm nothing has been missed before completing the deletion.
The complete vault.ts module
Here is the full module assembled from the sections above. Copy it into your project and adapt the field types to match your data model.
This module belongs in your service layer, called from user profile handlers and account deletion flows. It should not be called directly from route handlers, for two reasons: keeping PII access centralized makes it easier to audit, and it keeps the decryption concern separate from the HTTP concern.
What to do next
Add finer-grained key context for regulated data. If your application handles PHI under HIPAA in addition to general PII under GDPR, add a second dimension to the key context:
This creates a separate encryption key boundary for regulated data without any additional infrastructure. Your HIPAA data and your general PII are cryptographically isolated from each other under the same organization, which simplifies compliance mapping considerably.
Offer BYOK for enterprise customers. Once your PII storage is built on Vault, enabling an enterprise customer to bring their own encryption key is a configuration step in the WorkOS Admin Portal. The application code does not change. The customer's IT team configures their customer-managed key (CMK) in AWS KMS, Azure Key Vault, or GCP KMS, and Vault uses it automatically for any operation with their organizationId in the context. See the BYOK documentation for setup details.
Use describeObject for lightweight audit checks. Because describeObject does not decrypt the value, you can call it on a schedule to verify that all expected PII objects exist, check their last-modified timestamps, and confirm nothing has been unexpectedly deleted. This gives you a lightweight audit trail without ever reading plaintext PII in your monitoring code.