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 for | Not for |
|---|---|
| API keys your customers give you | your own app's keys: those belong in environment variables |
| Bot tokens and webhook secrets per customer | anything 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
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:
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:
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:
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:
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.
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"); // disconnectSetting 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 pass | Owner | Who 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:
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:
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()increateMcpConnections): each user's tokens are saved in the vault undermcp_oauth:<connection>:…, refreshed when they expire, and removed when the user disconnects. - A key you supply (
bearer()):createMcpConnections({ vault, secrets: vault.resolver, … })andbearer({ getToken: (p) => vault.get(p, "crm_token") })send it as the server'sAuthorizationheader.
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
| Mistake | What goes wrong | Instead |
|---|---|---|
A key in the instructions, params or data-context | Saved in the conversation, sent to the model, and possibly repeated in an answer | Read it inside the tool |
| A tool returning the raw response, headers included | The model can see, and repeat, whatever came back | Return only the fields the model needs |
| Reading the key in your route and passing it along | It isn't there when an answer continues, and it travels further than needed | Read it in the tool from the restored principal |
| The master key in the database or the repo | Encryption protects nothing | An environment variable or a key manager |
| Swapping the master key like an API key | Every secret saved under the old key becomes unreadable | Rotate it: the new key with an id, the old one in previousKeys, then vault.rotate() |
Go deeper
- Every method: Vault reference
- Signing in to services: Connecting accounts, Connect other services