In this article
June 16, 2026
June 16, 2026

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

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

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_KEY set in your environment

Setup

Install the WorkOS Node SDK:

  
npm install @workos-inc/node
  

Initialize the client once and export it for use across the application:

  
// lib/workos.ts
import { WorkOS } from '@workos-inc/node';

export const workos = new WorkOS(process.env.WORKOS_API_KEY!);
  

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:

  
pii:{userId}:{fieldName}
  

So for user usr_abc123:

  • pii:usr_abc123:email
  • pii:usr_abc123:phone
  • pii: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

  
// lib/vault.ts
import { workos } from './workos';

export async function storePiiField(
  userId: string,
  organizationId: string,
  field: 'email' | 'phone' | 'billing_address',
  value: string,
): Promise<string> {
  const object = await workos.vault.createObject({
    name: `pii:${userId}:${field}`,
    value,
    context: { organizationId },
  });

  // Return the object ID to store in your database
  return object.id;
}
  

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:

  
export async function storeUserPii(user: {
  id: string;
  organizationId: string;
  email: string;
  phone: string;
  billingAddress: string;
}): Promise<{
  emailVaultId: string;
  phoneVaultId: string;
  addressVaultId: string;
}> {
  const [emailObj, phoneObj, addressObj] = await Promise.all([
    workos.vault.createObject({
      name: `pii:${user.id}:email`,
      value: user.email,
      context: { organizationId: user.organizationId },
    }),
    workos.vault.createObject({
      name: `pii:${user.id}:phone`,
      value: user.phone,
      context: { organizationId: user.organizationId },
    }),
    workos.vault.createObject({
      name: `pii:${user.id}:billing_address`,
      value: user.billingAddress,
      context: { organizationId: user.organizationId },
    }),
  ]);

  return {
    emailVaultId: emailObj.id,
    phoneVaultId: phoneObj.id,
    addressVaultId: addressObj.id,
  };
}
  

After calling this, your database row for the user looks something like this:

  
id               | usr_abc123
org_id           | org_acmecorp
email_vault_id   | secret_AAA1B2C3D4E5F6A7B8C9D0E1F2A3B4
phone_vault_id   | secret_BBB1B2C3D4E5F6A7B8C9D0E1F2A3B4
addr_vault_id    | secret_CCC1B2C3D4E5F6A7B8C9D0E1F2A3B4
  

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

  
export async function readPiiField(vaultId: string): Promise<string> {
  const object = await workos.vault.readObject({ id: vaultId });
  return object.value;
}
  

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

  
export async function readUserPii(vaultIds: {
  emailVaultId: string;
  phoneVaultId: string;
  addressVaultId: string;
}): Promise<{
  email: string;
  phone: string;
  billingAddress: string;
}> {
  const [emailObj, phoneObj, addressObj] = await Promise.all([
    workos.vault.readObject({ id: vaultIds.emailVaultId }),
    workos.vault.readObject({ id: vaultIds.phoneVaultId }),
    workos.vault.readObject({ id: vaultIds.addressVaultId }),
  ]);

  return {
    email: emailObj.value,
    phone: phoneObj.value,
    billingAddress: addressObj.value,
  };
}
  

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:

  
const metadata = await workos.vault.describeObject({ id: vaultId });
// metadata.metadata.versionId is available without decrypting the value
  

Use describeObject for:

  • Checking whether a PII field exists for a user
  • Reading the versionId before 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

  
export async function updatePiiField(
  vaultId: string,
  newValue: string,
): Promise<void> {
  await workos.vault.updateObject({
    id: vaultId,
    value: newValue,
  });
}
  

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:

  
export async function updatePiiFieldSafely(
  vaultId: string,
  newValue: string,
): Promise<void> {
  // Read the current version without decrypting the value
  const current = await workos.vault.describeObject({ id: vaultId });

  // Update, providing the current version as a consistency lock
  await workos.vault.updateObject({
    id: vaultId,
    value: newValue,
    versionCheck: current.metadata.versionId,
  });
}
  

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

  
export async function deletePiiField(vaultId: string): Promise<void> {
  await workos.vault.deleteObject({ id: vaultId });
}
  

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

  
export async function deleteUserPii(vaultIds: {
  emailVaultId: string;
  phoneVaultId: string;
  addressVaultId: string;
}): Promise<void> {
  await Promise.all([
    workos.vault.deleteObject({ id: vaultIds.emailVaultId }),
    workos.vault.deleteObject({ id: vaultIds.addressVaultId }),
    workos.vault.deleteObject({ id: vaultIds.phoneVaultId }),
  ]);
}
  

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:

  
export async function listUserPiiObjects(userId: string) {
  return workos.vault.listObjects({
    search: `pii:${userId}:`,
  });
}
  

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.

  
// lib/vault.ts
import { workos } from './workos';

type PiiField = 'email' | 'phone' | 'billing_address';

function objectName(userId: string, field: PiiField): string {
  return `pii:${userId}:${field}`;
}

// Store a single PII field and return its Vault object ID.
// Save the returned ID in your database, not the plaintext value.
export async function storePiiField(
  userId: string,
  organizationId: string,
  field: PiiField,
  value: string,
): Promise<string> {
  const object = await workos.vault.createObject({
    name: objectName(userId, field),
    value,
    context: { organizationId },
  });
  return object.id;
}

// Store all PII fields for a user in parallel.
// Returns an object with Vault IDs to store in the users table.
export async function storeUserPii(user: {
  id: string;
  organizationId: string;
  email: string;
  phone: string;
  billingAddress: string;
}): Promise<{
  emailVaultId: string;
  phoneVaultId: string;
  addressVaultId: string;
}> {
  const [emailObj, phoneObj, addressObj] = await Promise.all([
    workos.vault.createObject({
      name: objectName(user.id, 'email'),
      value: user.email,
      context: { organizationId: user.organizationId },
    }),
    workos.vault.createObject({
      name: objectName(user.id, 'phone'),
      value: user.phone,
      context: { organizationId: user.organizationId },
    }),
    workos.vault.createObject({
      name: objectName(user.id, 'billing_address'),
      value: user.billingAddress,
      context: { organizationId: user.organizationId },
    }),
  ]);

  return {
    emailVaultId: emailObj.id,
    phoneVaultId: phoneObj.id,
    addressVaultId: addressObj.id,
  };
}

// Read and decrypt all PII fields for a user.
// Only call this when the plaintext values are actually needed.
export async function readUserPii(vaultIds: {
  emailVaultId: string;
  phoneVaultId: string;
  addressVaultId: string;
}): Promise<{
  email: string;
  phone: string;
  billingAddress: string;
}> {
  const [emailObj, phoneObj, addressObj] = await Promise.all([
    workos.vault.readObject({ id: vaultIds.emailVaultId }),
    workos.vault.readObject({ id: vaultIds.phoneVaultId }),
    workos.vault.readObject({ id: vaultIds.addressVaultId }),
  ]);

  return {
    email: emailObj.value,
    phone: phoneObj.value,
    billingAddress: addressObj.value,
  };
}

// Update a single PII field with an optimistic concurrency lock.
// Reads the current version with describeObject to avoid an unnecessary decryption.
export async function updatePiiField(
  vaultId: string,
  newValue: string,
): Promise<void> {
  const current = await workos.vault.describeObject({ id: vaultId });

  await workos.vault.updateObject({
    id: vaultId,
    value: newValue,
    versionCheck: current.metadata.versionId,
  });
}

// List all Vault objects for a user by name prefix.
// Useful for auditing before account deletion.
export async function listUserPiiObjects(userId: string) {
  return workos.vault.listObjects({
    search: `pii:${userId}:`,
  });
}

// Delete all PII fields for a user in parallel.
// After calling this, null out the Vault ID columns in your database.
export async function deleteUserPii(vaultIds: {
  emailVaultId: string;
  phoneVaultId: string;
  addressVaultId: string;
}): Promise<void> {
  await Promise.all([
    workos.vault.deleteObject({ id: vaultIds.emailVaultId }),
    workos.vault.deleteObject({ id: vaultIds.phoneVaultId }),
    workos.vault.deleteObject({ id: vaultIds.addressVaultId }),
  ]);
}
  

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:

  
context: { organizationId: user.organizationId, dataClass: 'phi' }
  

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.

Resources