Agent Data Encryption at Rest: Implementing Envelope Encryption

Envelope encryption in practice: a per-org key hierarchy, AES-256-GCM fields, and the AAD binding that stops a ciphertext being moved.

Diyan BogdanovDiyan Bogdanov4 min read
#encryption#security#aes-256

Disk encryption protects you from someone stealing the disk. It does nothing about the threat that actually matters for multi-tenant agent infrastructure: a query that returns the wrong tenant's row, a backup restored into the wrong environment, a ciphertext copied out of one record and into another.

Those need encryption at the field level, with keys that differ per organization. Here is how that is built, including the part most write-ups skip.

Three keys, and where each one lives#

The hierarchy is standard envelope encryption. What matters is the custody of each layer.

KeyWhat it doesWhere it lives
Root keyDerives every KEKFIELD_ENCRYPTION_KEY, held in GCP Secret Manager, injected at deploy
KEKWraps one organization's DEKDerived from the root key via HKDF with a per-org salt. Never stored
DEKEncrypts the actual field valuesRandom AES-256 key, stored wrapped in Organization.encryptedDek

Data is encrypted with the DEK. The DEK is encrypted with the KEK. The KEK is derived on demand from a root key the process reads out of its environment.

Where the root key is not

The root key is a secret injected into the runtime, not key material held inside a hardware security module. An HSM or KMS performs operations with a key that never leaves its boundary; this design reads the root key into process memory and derives from it there. Worth being precise about, because the two get conflated and they carry materially different guarantees.

Compromise of one DEK exposes one organization. A database backup taken without the root key exposes nothing usable, because the wrapped DEKs are themselves AES-256-GCM ciphertexts.

What an encrypted value actually is#

Every encrypted field is a base64 string behind an enc:v1: prefix, and the bytes under that prefix are a structured envelope rather than bare ciphertext:

[version:1][iv:12][authTag:16][ciphertext:N]
  • version — one byte, so the algorithm can migrate later without ambiguity
  • iv — 12 random bytes, the length NIST SP 800-38D mandates for GCM
  • authTag — 16 bytes, a 128-bit tag, GCM's maximum
  • ciphertext — the encrypted value

The prefix earns its place operationally. It makes a plaintext value sitting in an encrypted column obvious at a glance — in a query result, a log line, a backup dump — instead of something discovered much later.

The part that stops a ciphertext being moved#

Encryption alone does not stop someone copying an encrypted blob out of one row and into another. If both records use the same key, the value decrypts perfectly in its new home. For an agent platform that is a real attack: move an encrypted webhook secret onto a webhook you control and the platform decrypts it for you.

Every field is bound to its context with Additional Authenticated Data. AAD is covered by the authentication tag but not encrypted, so a ciphertext presented with the wrong context fails to authenticate — decryption throws rather than quietly returning the wrong answer.

Each field declares its own template:

FieldAAD template
Organization.masterKeyorg:{id}:masterKey
Webhook.secretwebhook:{id}:secret
Webhook.authSecretwebhook:{id}:authSecret
Domain.verificationTokendomain:{id}:verificationToken
OAuthCustomApp.clientSecretoauth_custom_app:{id}:clientSecret
Agent.privateKeyEncagent:{id}:privateKeyEnc

Because the row id sits inside the AAD, a webhook secret is bound to that webhook. Relocating it does not produce garbage; it fails closed.

That table is also the honest scope. Six fields, encrypted transparently by a Prisma extension so application code reads and writes plaintext and cannot forget to call the cipher. Message bodies are not on the list — they live in the mail infrastructure, where the protections are access control and transport security rather than per-org field encryption.

Vault credentials get a stronger guarantee#

Credentials are the case where encryption at rest is not the interesting property. A secret that gets decrypted and handed to an agent is a secret in an LLM's context window.

Storing one is typed by credential kind rather than a generic key/value put:

import { Anima } from "@anima-labs/sdk";
 
const anima = new Anima({ apiKey: process.env.ANIMA_API_KEY! });
 
const credential = await anima.vault.createCredential({
  type: "api_key",
  name: "Stripe (live)",
  apiKey: {
    provider: "stripe",
    key: "sk_live_51P...",
  },
});
// credential.id is the handle you keep. The key itself reads back masked.

Reading it back by id returns the masked form:

const masked = await anima.vault.getCredential(credential.id);

The stronger path is not to retrieve it at all. useCredential has Anima make the outbound request and inject the credential in transit:

const response = await anima.vault.useCredential(credential.id, {
  method: "POST",
  url: "https://api.stripe.com/v1/refunds",
  body: "charge=ch_123",
});

The URL's host must be on that credential's allowlist, and any Authorization header the caller sets is discarded and replaced. Set the credential's reveal policy to brokered and the plaintext has no read path at all: reveal and export are refused for every key type, including the organization's master key. Recovery from a lost brokered credential is rotation, because there is nothing left to recover.

That is the property worth designing toward. Not "the secret is encrypted" but "the agent never holds the secret."

Rotation without re-encrypting anything#

Because field values are encrypted with the DEK and only the DEK is wrapped by the KEK, rotating the root key never touches the encrypted rows. rotateOrganizationKek unwraps the DEK with the current KEK, re-wraps it under the new one, and writes back a fresh encryptedDek and kekVersion. Every field ciphertext is left exactly as it was.

Rotation is therefore one small write per organization rather than a re-encryption pass over every row — which is the difference between rotating keys on a schedule and rotating them only once an incident has forced the question.

Stay Updated

Get the latest on AI agent identity, delivered weekly.