In this article
June 15, 2026
June 15, 2026

Cryptographic key isolation in multi-tenant SaaS

What "isolation" actually means at the key level, how to implement it with key context, and what your blast radius looks like when something goes wrong.

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

Your SaaS app encrypts data at rest. You use a managed database with encryption enabled. Backups are encrypted. At your last security review, you checked the encryption box and moved on.

Then an engineer writes a bad query. A misconfigured API endpoint returns rows from the wrong organization. An attacker exploits a privilege escalation bug and runs queries as an admin. A database administrator exports a table they shouldn't have touched.

In every one of these scenarios, your encryption did nothing. Because encryption at rest protects against someone physically stealing a hard drive. It does not protect against the application layer, where all the legitimate decryption happens, and where most of the real attacks occur.

The question isn't whether you encrypt. It's where the key boundary sits and who can cross it.

This post is about building that boundary correctly in a multi-tenant SaaS application: what cryptographic isolation actually means, how to implement it using key context, and what your blast radius looks like when something does go wrong.

Logical isolation vs. cryptographic isolation

Most developers building multi-tenant applications default to what you might call logical isolation. Tenant data lives in the same database, separated by a tenant_id column, a separate schema, or a separate table. The application enforces the boundary by scoping every query to the authenticated user's organization.

Logical isolation is necessary. It is not sufficient.

The boundary is enforced by code. A bug in a query, a misconfigured row-level security policy, a compromised admin session, or a direct database connection bypasses it entirely. The data is there; the only thing standing between an attacker and a different tenant's records is the application logic.

Cryptographic isolation is a different and stronger property. Each tenant's data is encrypted with a key that no other tenant's decryption path can reach. The boundary is enforced by mathematics, not code.

If Org A's data is encrypted with Key A and Org B's session has a bug that leaks Org A's encrypted ciphertext, Org B still cannot read it. Without Key A, the ciphertext is random noise. The application bug caused a data leak; the cryptographic boundary prevented a data breach.

What isolation guarantees

When you have true cryptographic isolation between tenants:

  • An attacker who compromises one tenant's key or session cannot decrypt another tenant's data.
  • An attacker who exfiltrates ciphertext from the database cannot decrypt it without the specific key used to encrypt it.
  • A person with direct database access sees encrypted blobs, not plaintext.

What isolation does not guarantee

This is equally important to understand, and skipping it would make the rest of this post misleading.

Cryptographic isolation does not protect against an attacker who compromises the application layer with valid credentials. If an attacker holds a session token or API key that the application considers authorized to call readObject for a given tenant, they get plaintext back. The key boundary exists at the encryption layer, not the API layer. Authorization is a separate concern and a necessary complement to isolation.

Isolation also does not protect against a compromise of the key management service itself, though using an HSM-backed service raises that bar considerably by making key material non-exportable.

The point is not that cryptographic isolation is a complete security solution. It is that it is a control that limits blast radius when other controls fail. Which they will, eventually.

Why common approaches fall short

Before getting to the right architecture, it is worth naming the patterns that most teams default to and why they fail to provide real isolation.

One key for everything

The most common pattern: a single encryption key for the whole application. All tenant data is encrypted with it. Encryption at rest is technically present. But cryptographically, all tenant data has the same blast radius. Compromise the key and you can decrypt every tenant's data. The blast radius is your entire customer base.

One key per service or data type

A more sophisticated version: different keys for the payments service, the user profile service, the audit log service. Still no tenant boundary. An attacker who compromises the payments service key can decrypt all tenants' payment data.

Per-tenant keys managed manually

Better in principle. In practice, the problems are operational. A hundred tenants means a hundred keys to rotate, audit, and revoke. The key-to-tenant mapping lives somewhere, which makes it a high-value target. Rotation gets skipped because it is painful to coordinate. New tenants require pre-provisioning new keys before any data can be written.

These operational burdens lead teams to cut corners, which erodes the isolation they were trying to create in the first place.

The right architecture solves for per-tenant isolation without requiring you to manage a registry of keys. That is what envelope encryption with key context gives you.

Envelope encryption: the architecture that scales

Envelope encryption is the foundation of how production key management systems work, including AWS KMS, Google Cloud KMS, Azure Key Vault, and WorkOS Vault. Understanding it properly is a prerequisite for understanding why key context works.

The core idea is two layers of keys with two different jobs.

  • Data Encryption Key (DEK): A random key generated fresh for each encryption operation. The DEK encrypts the actual data. It is short-lived and unique per object. You never store the plaintext DEK anywhere.
  • Key Encryption Key (KEK): A long-lived key tied to a tenant or context. The KEK encrypts the DEK, not the data directly. The KEK lives in an HSM and cannot be exported to plaintext; it can only be used to perform cryptographic operations.

What gets stored is the pair: the ciphertext of your data (encrypted with the DEK) and the ciphertext of the DEK itself (encrypted with the KEK). To read the data, you first ask the key management service to decrypt the DEK using the KEK, then use the resulting plaintext DEK to decrypt the data. The plaintext DEK exists in memory only for the duration of the operation.

Why this design is right for multi-tenancy

The separation of jobs between DEK and KEK creates two useful properties that are hard to achieve any other way.

First, KEK rotation does not require re-encrypting your data. Because the KEK encrypts DEKs, not data, you can rotate a KEK by re-encrypting the DEKs associated with it. The data itself does not need to move. For tenants with large datasets, this is the difference between a rotation that takes milliseconds and one that takes hours.

Second, each tenant gets their own KEK. Cryptographic isolation is enforced at the KEK boundary. There is no path from Tenant A's KEK to Tenant B's data because Tenant B's DEKs were never wrapped with Tenant A's KEK. The math prevents it.

And even within a single tenant, each encrypted object has a unique DEK. Compromising one object does not expose others, even under the same KEK. The blast radius is always bounded.

How Vault implements isolation

WorkOS Vault builds on envelope encryption and adds a mechanism that eliminates the need to manage a key registry yourself: key context.

When you encrypt or store data with Vault, you provide a key_context object alongside the value. This is a set of key-value pairs that tells Vault which KEK to use for the operation.

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

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

const object = await workos.vault.createObject({
  name: 'acme-stripe-key',
  value: 'sk_live_...',
  context: { organizationId: 'org_acmecorp' },
});
  

The key context { organizationId: 'org_acmecorp' } is the tenant boundary. Vault resolves (or creates just-in-time) the KEK associated with that context. A freshly generated DEK encrypts the value. The DEK is wrapped with the KEK and stored alongside the ciphertext.

When Org Beta's data is stored:

  
const object = await workos.vault.createObject({
  name: 'beta-stripe-key',
  value: 'sk_live_...',
  context: { organizationId: 'org_betacorp' },
});
  

A completely different KEK is used. The two tenants' encrypted objects share no key material. Reading Acme's object requires Acme's KEK. Reading Beta's object requires Beta's KEK. There is no shared path.

What the context model changes for developers

The most significant thing about key context is that you manage context, not keys. There is no key registry to build, no database column mapping tenant IDs to key IDs, no pre-provisioning step when a new customer signs up.

When you create an object with a new organizationId in the context, Vault creates the corresponding KEK just-in-time. When you read the object back, Vault resolves the correct KEK from the stored context. The key hierarchy is derived from the context declaratively, without developer intervention.

  
// Reading back an object -- Vault resolves the correct KEK automatically
const object = await workos.vault.readObject({ id: 'secret_51B0AC67C2FB4247AC5ABDDD3C701BDC' });
console.log(object.value); // plaintext, decrypted using the correct KEK for this object's context
  

Multi-dimensional context for finer isolation

The key context can express more than a single tenant boundary. You can provide multiple key-value pairs to create finer-grained isolation within a tenant:

  
// PII stored under a stricter key boundary than general secrets
await workos.vault.createObject({
  name: 'acme-user-pii',
  value: JSON.stringify({ email: 'alice@acmecorp.com', ssn: '...' }),
  context: {
    organizationId: 'org_acmecorp',
    dataType: 'pii',
  },
});

// Credentials stored under a separate boundary
await workos.vault.createObject({
  name: 'acme-api-credentials',
  value: 'sk_live_...',
  context: {
    organizationId: 'org_acmecorp',
    dataType: 'credentials',
  },
});
  

Each unique combination of context values maps to a separate KEK. For compliance programs that require different key policies for PHI versus non-PHI data, or for payment data versus user data, this is an architectural primitive rather than a separate system to build.

Vault enforces a few limits on the context object: all values must be strings, a maximum of 10 key-value pairs per context, keys up to 120 characters, and values up to 500 characters. In practice, most applications only need two or three context dimensions.

The version check for safe updates

When rotating stored secrets, you can supply a versionCheck parameter to prevent a race condition overwriting a concurrent update:

  
const updatedObject = await workos.vault.updateObject({
  id: object.id,
  value: 'sk_live_new_rotated_key',
  versionCheck: object.metadata.versionId,
});
  

If another process updated the object between your read and your write, the version check fails instead of silently overwriting. For secrets that are rotated by automated processes, this prevents the kind of subtle corruption that is hard to debug after the fact. Note that the key context for an object cannot be changed after creation -- only the value can be updated.

Threat modeling: What a tenant key compromise actually looks like

This is the scenario most architecture guides skip. Let's walk through it concretely.

The setup

Your application has two enterprise tenants: Acme Corp and Beta Systems. Both have enabled Bring Your Own Key (BYOK), meaning each has configured their own customer-managed key (CMK) in AWS KMS, and Vault uses those CMKs as KEKs when encrypting their data.

An attacker compromises Acme Corp's AWS account and gains access to their CMK. They can now call kms:Decrypt using Acme's key.

What the attacker can access

Any DEK that was wrapped with Acme's CMK is now decryptable. That means the attacker can decrypt any Vault object created with { organizationId: 'org_acmecorp' } in the key context.

In practice: Acme's stored OAuth tokens, API credentials, encrypted PII, and any other secrets stored under their organization context. The scope of the breach is everything Acme had stored in Vault. That is a serious incident for Acme.

What the attacker cannot access

Beta Systems' data. Beta's DEKs were wrapped with Beta's CMK, not Acme's. Acme's compromised key cannot decrypt a ciphertext that was wrapped with a different key. The mathematical boundary holds.

The attacker also cannot access data from tenants who use WorkOS-managed KEKs (tenants who have not configured BYOK). Those KEKs live in HSMs inside WorkOS's infrastructure. Having Acme's AWS credentials gives no access to a different key management system.

And even within Acme's data, if Acme used multi-dimensional context (say, separate context for dataType: 'pii' and dataType: 'credentials'), those are separate KEKs. Compromising Acme's main CMK does not automatically give access to a differently-keyed subset unless that subset was also wrapped with the same CMK.

Containment in practice

When Acme's compromise is detected, the response is scoped:

  1. Acme disables their CMK in AWS. Vault can no longer use it, which immediately prevents any further decryption of Acme's objects.
  2. Acme provisions a new CMK, re-enables BYOK with the new key, and Vault re-wraps Acme's DEKs during the next write cycle.
  3. No other tenant is affected. There is no platform-wide incident. No emergency communication to your full customer base.
  4. AWS CloudTrail shows exactly which kms:Decrypt calls were made with Acme's CMK and when, giving Acme a full audit trail of what was accessed during the window of compromise.

Compare this to an application without cryptographic isolation. A single shared key means the same AWS credential compromise could expose all tenant data. Containment becomes impossible. The incident is not "Acme had a breach" -- it is "we had a breach."

The application-layer gap

To be direct about what cryptographic isolation does not cover: if the attacker had compromised a valid application session or API key with legitimate access to Acme's tenant, they would have gotten plaintext back from readObject -- because the application would have correctly identified the request as authorized and decrypted the data on their behalf.

This is not a flaw in the isolation model. It is a reminder that cryptographic isolation and authorization are separate controls. Isolation limits what an attacker can do with raw ciphertext or a compromised key. Authorization limits what an attacker can do with a compromised application credential. You need both. Neither replaces the other.

A complete defense-in-depth posture for secrets in a multi-tenant application includes: cryptographic isolation at the key level (Vault), per-tenant authorization at the API level (your application layer and FGA), short-lived credentials and rotation policies, and audit logging that makes anomalous access patterns visible.

Putting it into practice

A few decisions that matter most when implementing key isolation in a real application.

  • Use organizationId as the baseline context. Every tenant gets its own KEK from day one. Do not start with a shared key and plan to migrate later -- migrating means re-encrypting all existing data, which is significantly more disruptive than setting the context correctly at the start.
  • Let Vault manage the key lifecycle. Do not build a key registry in your own database. The context is stored alongside the encrypted object; Vault resolves the correct KEK at decryption time. KEK rotation is handled internally. The operational burden that makes manual per-tenant key management unsustainable simply does not exist in this model.
  • Add data type context for regulated data. If your application handles PHI, PCI data, or other regulated categories that have specific key control requirements, express that as a second dimension in the context. { organizationId: '...', dataType: 'phi' } gives PHI data a separate KEK from general application secrets, without adding any infrastructure.
  • Offer BYOK for enterprise customers. Per-tenant key isolation is the prerequisite for BYOK. Once your architecture uses context-based KEKs, enabling BYOK for an enterprise customer is a configuration step in the Admin Portal, not an architecture change. Acme's IT admin configures their CMK through the portal; from that point forward, Vault uses it automatically for any operation with { organizationId: 'org_acmecorp' } in the context.
  • Pair with application-layer authorization. Vault handles the data layer. Your authorization model handles the API layer. The sentence you want to be able to say to your customers is: even if our authorization layer had a bug that exposed your ciphertext to another tenant, their key cannot decrypt it. That sentence requires both layers to be in place.

The guarantee

Cryptographic isolation changes what you can promise to your customers.

Without it, your promise is: we have access controls in place, and we believe they work correctly. That promise is built on code, which can have bugs.

With it, the promise becomes: your data is cryptographically isolated from every other tenant. In the event of a breach affecting another customer on this platform, their key cannot decrypt your data.

That is not a policy statement. It is a mathematical property enforced at the key level, before the data ever reaches a database. The isolation does not depend on your application logic being correct; it depends on the laws of cryptography.

That is what isolation means when it is implemented properly.

Next steps

Get started with Vault, read about key context, or explore BYOK configuration for your enterprise customers.