Secrets (the vault)

Per-customer API keys and sign-in tokens, encrypted in your database, used by your tools and never seen by the model.

Tools often need a credential that belongs to a customer: the CRM key they pasted into your settings, their Slack bot's token, the token from their Linear sign-in. The vault keeps these encrypted in your own database, per customer, and hands them only to your server code. The model never sees them.

Use the vault forNot for
API keys your customers give youyour own app's keys: those belong in environment variables
Bot tokens and webhook secrets per customeranything the model should read: that goes in the context or a tool result
OAuth tokens from users connecting a service (Latch stores these itself)what a chat is about: that's params

Set it up

lib/vault.ts
import { createVault } from "@intentface/latch-core";
import { createPostgresSecretStore } from "@intentface/latch-drizzle";

export const vault = createVault<Principal>({
  // Whose secret it is: a person, or the whole team when there's no userId.
  store: createPostgresSecretStore(db, {
    owner: (p) => (p.userId ? [p.orgId, p.userId] : [p.orgId]),
  }),
  masterKey: process.env.LATCH_VAULT_KEY!, // 32 random bytes, base64
});

Generate the master key once and keep it in your environment or key manager, never in the database:

bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

On Node, deriveMasterKey(passphrase, { salt, cost: 2 ** 17 }) from @intentface/latch-core/node turns a passphrase into a master key, if that's easier to manage. The salt must stay the same for the deployment. Leave out cost only for a deployment that already derives its key without it: a different cost gives a different key.

Rotating the master key

Rotate when someone who had the key leaves, when it may have leaked, or on a schedule. Give the new key an id, and keep the old one readable while secrets move over:

lib/vault.ts
export const vault = createVault<Principal>({
  store,
  masterKey: { id: "2026-10", key: process.env.LATCH_VAULT_KEY! },
  previousKeys: [{ id: "default", key: process.env.LATCH_VAULT_KEY_OLD! }],
});

A plain string key has the id default, so that's the old key's id the first time. From then on, new and updated secrets are saved under the new key, and secrets under the old one still read. Then, from a script or an admin route, once the new config is deployed everywhere:

ts
const { rewrapped, remaining, errors } = await vault.rotate();

It moves every secret onto the new key. Only each secret's own small data key is re-encrypted, never the values, so it's quick even with many secrets, and safe to run again or stop part-way. Run it only once every instance is on the new config: an instance still on the old one keeps saving secrets under the old key, including ones rotate() has already passed, and remaining can't count those. With every instance on the new config, remaining: 0 means nothing needs the old key any more: remove it from previousKeys and from your environment. A secret saved while it ran can count in remaining; run it again. A secret that no configured key opens is listed in errors and left as it is.

A master key in a key service

To keep the master key out of your app altogether, in AWS KMS, Google Cloud KMS, HashiCorp Vault or your own HSM, give the vault a key it calls instead of one it holds: an id, and functions that wrap and unwrap a secret's data key through your service. Here, AWS KMS:

lib/vault.ts
import { DecryptCommand, EncryptCommand, KMSClient } from "@aws-sdk/client-kms";
import { createVault, type VaultKeyProvider } from "@intentface/latch-core";

const kms = new KMSClient({});
const KeyId = process.env.VAULT_KMS_KEY_ARN!;

const awsKms: VaultKeyProvider = {
  id: "kms-1",
  async wrap(dataKey, { context }) {
    const out = await kms.send(new EncryptCommand({ KeyId, Plaintext: dataKey, EncryptionContext: context }));
    return out.CiphertextBlob!;
  },
  async unwrap(wrapped, { context }) {
    const out = await kms.send(new DecryptCommand({ KeyId, CiphertextBlob: wrapped, EncryptionContext: context }));
    return out.Plaintext!;
  },
};

export const vault = createVault<Principal>({ store, masterKey: awsKms });

context names the secret and, with the shipped stores, its owner. Pass it on, as the encryption context or, for services that take bytes (Google Cloud KMS's additionalAuthenticatedData), as aad, so the service refuses a secret copied into another owner's row. Each get and set makes one call to the service. To move existing secrets over, put your current key in previousKeys and run vault.rotate(), as above.

Each secret is also tied to the row it's saved in, its owner and its name: a record copied into another owner's row doesn't decrypt there. (With a store of your own, that takes ownerKeyOf; without it, secrets are tied to their name only.) Secrets saved before 0.21 aren't tied yet: rotate() ties them as it moves them, and running it once without a new key ties them where they are. Once a deployment saves secrets with 0.21, an older version can't read those, so don't roll back past it.

Put secrets in

Your own settings page saves them. The vault is plain server code; nothing in Latch exposes it over HTTP.

app/api/settings/crm/route.ts
export async function POST(req: Request) {
  const user = await requireAdmin(req);                       // your check: who may set it
  const { apiKey } = await req.json();
  await vault.set({ orgId: user.orgId }, "crm_api_key", apiKey); // the whole team's key
  return Response.json({ connected: true });                  // never send the value back
}

// elsewhere
await vault.has({ orgId }, "crm_api_key");   // "connected" in your settings UI
await vault.list({ orgId });                 // key names, never values
await vault.delete({ orgId }, "crm_api_key"); // disconnect

Setting a key again replaces it. That's how a customer rotates one: the next tool call uses the new value.

Whose secret is it?

The principal you pass, and the store's owner, decide. With the setup above:

You passOwnerWho shares it
{ orgId, userId }[orgId, userId]one person, like their personal API key
{ orgId }[orgId]the whole team, like the company's CRM key

A tool reads it back with the same shape: vault.get({ orgId: principal.orgId }, "crm_api_key") for the team's key, vault.get(principal, …) for a personal one. Another customer's key is never found, the same way another customer's chat isn't.

The secret store's owner is separate from the chat storage's, so team-wide credentials can sit next to private chats. It also receives the key, for when one kind of secret should always be personal and another always shared:

ts
createPostgresSecretStore(db, {
  owner: (p, key) => (key?.startsWith("team:") || !p.userId ? [p.orgId] : [p.orgId, p.userId]),
});

vault.list passes no key, so owner decides from the principal alone: here vault.list(principal) lists the personal keys and vault.list({ orgId }) the team's. Make sure your owner handles a call without a key.

Use them in tools

A tool reads the secret when it runs, uses it to call the service, and returns only the result:

ts
const lookup_customer = tool({
  description: "Look up a customer in the CRM",
  inputSchema: z.object({ email: z.string() }),
  execute: async ({ email }, { context }) => {
    const { principal } = context as ToolContext<Principal, Ctx>;
    const apiKey = await vault.get({ orgId: principal.orgId }, "crm_api_key");
    if (!apiKey) return { error: "Connect your CRM in Settings first." };
    const res = await fetch(`https://crm.example.com/customers?email=${encodeURIComponent(email)}`, {
      headers: { authorization: `Bearer ${apiKey}` },
    });
    const customer = await res.json();
    return { name: customer.name, plan: customer.plan }; // the data, never the key
  },
});

Reading it inside the tool, rather than in your route, also means it's there when an answer continues after an approval or a crash: tools run with the restored principal, so the lookup works the same.

Connections use the same vault

  • Signing in to a service (oauth() in createMcpConnections): each user's tokens are saved in the vault under mcp_oauth:<connection>:…, refreshed when they expire, and removed when the user disconnects.
  • A key you supply (bearer()): createMcpConnections({ vault, secrets: vault.resolver, … }) and bearer({ getToken: (p) => vault.get(p, "crm_token") }) send it as the server's Authorization header.

Either way the token goes from the vault to the request, and never into the conversation.

What protects them

  • Encrypted per secret. Each value is encrypted with its own random key (AES-256-GCM), and that key is encrypted with your master key. A copy of the database alone reveals nothing.
  • Server only. Values reach your code and the requests it makes. The model, the browser, logs and traces see them only if your code puts them there.
  • Scoped by owner. Reads are limited to the owner the principal maps to.

Common mistakes

MistakeWhat goes wrongInstead
A key in the instructions, params or data-contextSaved in the conversation, sent to the model, and possibly repeated in an answerRead it inside the tool
A tool returning the raw response, headers includedThe model can see, and repeat, whatever came backReturn only the fields the model needs
Reading the key in your route and passing it alongIt isn't there when an answer continues, and it travels further than neededRead it in the tool from the restored principal
The master key in the database or the repoEncryption protects nothingAn environment variable or a key manager
Swapping the master key like an API keyEvery secret saved under the old key becomes unreadableRotate it: the new key with an id, the old one in previousKeys, then vault.rotate()

Go deeper