Envelope Encryption for Tenant Records
Envelope encryption gives every tenant a cryptographic boundary that survives a database compromise, and this page shows how to wire it into ordinary row storage without a key-management call on every query. It is a focused part of Per-Tenant Encryption & Key Management: the key hierarchy, the binding that stops ciphertext moving between tenants, and the caching that makes it affordable.
Problem Framing
Calling a key management service to encrypt every field would add tens of milliseconds and a hard availability dependency to every write. Encrypting everything with one platform-wide key removes the per-tenant boundary entirely and makes deleting one customer's data from an archive impossible. Envelope encryption resolves both: a fast symmetric data key encrypts the record, and a slow, well-guarded tenant key encrypts the data key.
The subtlety specific to multi-tenancy is that ciphertext alone is not a boundary. If tenant A's encrypted column value is copied into tenant B's row and B's data key happens to be the same one โ or an attacker with database write access moves both the ciphertext and its wrapped key โ decryption succeeds and the boundary is gone. Additional authenticated data closes this: bind the ciphertext to the tenant, table, column and row identifier, so decryption fails unless the context matches exactly.
The third property that makes this worth the effort is crypto-shredding. Destroying a tenant key renders every record encrypted under it unrecoverable, including copies in backups and archives you cannot rewrite โ which is the only practical answer to the retention problem described in Tenant Offboarding & Data Retention Workflows.
Step-by-Step Guide
1. Store the wrapped key beside the ciphertext
Keeping wrapped data keys in a separate table adds a join to every read and a consistency problem to every write. Store the envelope inline, versioned, so a row is self-describing.
ALTER TABLE patient_note
ADD COLUMN body_ct bytea, -- AES-256-GCM ciphertext
ADD COLUMN body_nonce bytea, -- 96-bit nonce
ADD COLUMN body_wrapped_dk bytea, -- data key, wrapped by the tenant key
ADD COLUMN key_version integer NOT NULL DEFAULT 1;
CREATE INDEX ON patient_note (tenant_id, key_version); -- drives re-wrap after rotation
The index on key_version is what makes rotation a bounded background job rather than a full-table rewrite, as covered in rotating tenant data encryption keys without downtime.
2. Generate a data key per record and wrap it once
The key management call happens once per record written, not once per field and never on read paths that hit the cache.
// envelope.ts
import { webcrypto as crypto } from 'node:crypto';
export async function sealField(
kms: Kms, tenantId: string, aad: Uint8Array, plaintext: Uint8Array,
) {
const dk = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']);
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce, additionalData: aad }, dk, plaintext,
));
const rawDk = new Uint8Array(await crypto.subtle.exportKey('raw', dk));
const wrapped = await kms.wrap(tenantId, rawDk, aad); // one KMS call per record
rawDk.fill(0);
return { ct, nonce, wrapped };
}
Zeroing rawDk after wrapping is a small discipline that keeps the plaintext key out of heap dumps and core files.
3. Bind the ciphertext to its exact location with AAD
The additional authenticated data is the tenant boundary. It is not encrypted, it is authenticated โ change any part of it and decryption fails with an authentication error rather than returning wrong plaintext.
// aad.ts
const enc = new TextEncoder();
export function fieldAad(tenantId: string, table: string, column: string, rowId: string): Uint8Array {
// Length-prefixed so 'ab'+'c' can never collide with 'a'+'bc'.
const parts = [tenantId, table, column, rowId];
const joined = parts.map((p) => `${p.length}:${p}`).join('|');
return enc.encode(`v1|${joined}`);
}
Length-prefixing looks pedantic and is not: without it, an attacker who controls part of a row identifier can construct a different context that serialises identically, which is precisely the kind of subtle break that never shows up in tests.
4. Cache unwrapped data keys with the tenant in the cache key
An unwrapped key cached under a record identifier alone is a cross-tenant leak waiting for a hash collision or a copy-paste bug. Include the tenant, bound the lifetime, and cap the size.
// dk-cache.ts
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, CryptoKey>({ max: 20_000, ttl: 5 * 60_000 });
export async function unwrapCached(kms: Kms, tenantId: string, wrapped: Uint8Array, aad: Uint8Array) {
const k = `dk:${tenantId}:${sha256hex(wrapped)}`; // tenant is part of the key, always
const hit = cache.get(k);
if (hit) return hit;
const raw = await kms.unwrap(tenantId, wrapped, aad); // fails if AAD does not match
const key = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['decrypt']);
raw.fill(0);
cache.set(k, key);
return key;
}
A five-minute TTL is a deliberate trade: it bounds how long a key survives after the tenant key is disabled, which is what makes crypto-shredding take effect in minutes rather than at process restart.
5. Shred by destroying the tenant key, and prove it
Crypto-shredding is only credible if the key is genuinely unrecoverable and you can show when it became so.
// shred.ts
export async function cryptoShred(kms: Kms, tenantId: string) {
await kms.disableKey(tenantId); // immediate: unwraps start failing
cache.clear(); // drop any warm data keys
const schedule = await kms.scheduleDeletion(tenantId, { pendingWindowDays: 7 });
await audit.write('tenant.key_shredded', {
tenantId, disabledAt: new Date().toISOString(), deletionAt: schedule.deletionDate,
});
return schedule;
}
The seven-day pending window is the industry norm and is a feature, not a delay: it is the rollback path if the offboarding turns out to have been triggered in error.
Verification
The property worth testing hardest is that a ciphertext is useless outside its exact context.
// envelope.spec.ts
it('refuses to decrypt a ciphertext moved to another tenant', async () => {
const aadA = fieldAad('tenant-a', 'patient_note', 'body', 'row-1');
const sealed = await sealField(kms, 'tenant-a', aadA, enc.encode('confidential'));
const aadB = fieldAad('tenant-b', 'patient_note', 'body', 'row-1');
await expect(openField(kms, 'tenant-b', aadB, sealed)).rejects.toThrow();
});
it('refuses to decrypt a ciphertext moved to another row of the same tenant', async () => {
const aad1 = fieldAad('tenant-a', 'patient_note', 'body', 'row-1');
const sealed = await sealField(kms, 'tenant-a', aad1, enc.encode('confidential'));
const aad2 = fieldAad('tenant-a', 'patient_note', 'body', 'row-2');
await expect(openField(kms, 'tenant-a', aad2, sealed)).rejects.toThrow();
});
it('stops decrypting within the cache TTL after the tenant key is disabled', async () => {
await cryptoShred(kms, 'tenant-a');
await vi.advanceTimersByTimeAsync(5 * 60_000 + 1000);
await expect(openField(kms, 'tenant-a', aadA, sealed)).rejects.toThrow();
});
Operationally, alert on unwrap failures by tenant: a sudden burst means either a rotation went wrong or someone is moving ciphertext around, and both deserve a human.
SELECT tenant_id, count(*) AS unwrap_failures
FROM crypto_event
WHERE event = 'unwrap_failed' AND at > now() - interval '1 hour'
GROUP BY tenant_id HAVING count(*) > 5;
The value of the scheme is easiest to see by asking what an attacker holding a stolen database dump can actually read under each option.
Failure Modes & Gotchas
-
Symptom: write latency jumps and the key service throttles under load. Cause: a data key is generated and wrapped per field rather than per record. Fix: one data key per record, reused across that record's encrypted columns with column-specific AAD.
-
Symptom: decryption returns another tenant's plaintext after a bulk copy. Cause: no AAD, so a ciphertext is portable between contexts. Fix: bind tenant, table, column and row identifier into length-prefixed AAD and treat authentication failure as a security event.
-
Symptom: a shredded tenant's data is still readable minutes later on some hosts. Cause: unwrapped data keys sit in an unbounded process cache. Fix: bound the cache with a short TTL and clear it explicitly during shredding.
-
Symptom: a rotation job rewrites every row and saturates the database. Cause: rotation re-encrypts payloads instead of re-wrapping data keys. Fix: rotate the tenant key and re-wrap data keys only; the ciphertext itself never has to change.
FAQ
Should each record get its own data key, or each tenant? Per record is the better default. It keeps the blast radius of any single exposed data key to one row, and it makes re-wrapping incremental. Per tenant is simpler and acceptable for low-sensitivity data, but it means one leaked data key exposes the entire tenant and rotation becomes a full re-encryption rather than a re-wrap. Whichever you choose, the AAD binding matters more than the granularity.
Can encrypted columns still be searched? Not with ordinary predicates โ that is the cost. Practical options are a deterministic blind index (an HMAC of the normalised value, with the tenant in the HMAC key) for equality lookups, keeping non-sensitive attributes in the clear for filtering, or moving search to a system where the tenant explicitly accepts the exposure. Order-preserving and fully searchable encryption schemes leak far more than teams expect and are rarely the right answer.
What happens if the key management service is unavailable? Reads served from the data-key cache continue; cache misses and all writes fail. That is the correct behaviour โ degrading to unencrypted writes would be worse than an outage. Reduce the blast radius by keeping the cache warm with a reasonable TTL, using a regional key service with high availability, and making sure the failure surfaces as an explicit error rather than a silent fallback path.