Guide/vault5 min read

How to Store API Keys for AI Agents Safely

How to store API keys for AI agents safely: keep the key out of the prompt, encrypt it per user, and let the agent use it server-side. A step-by-step guide.

Read the docs →

Guide/vault
TL;DR
  • Create a tenant user one identity per customer so each key is isolated to its owner.
  • Store the key with vault put it's envelope-encrypted with AWS KMS and tagged `kind: api_key`.
  • Lock it if the agent shouldn't read it a `locked` entry is used server-side but never revealed.
  • Reveal over POST when a call needs it the secret returns in the body, never in a URL.
  • Expire and rotate set `expires_at` for short-lived keys and use `vault_rotate` to re-wrap.

Your AI agent needs an API key to do real work — call a payments API, hit a partner service, resume a logged-in session. The wrong way to give it one is to paste the key into the prompt or pass it as a tool argument, where it can leak through prompt injection or logs. The right way is to store the key encrypted, per customer, and let the platform use it server-side.

This is a step-by-step version of the pattern in secrets management for AI agents, using Naïve's vault. Every step is a single call.

What counts as an "API key" here?

Before the steps, a quick definition, because it's broader than it sounds. For an AI agent, an "API key" is any long-lived credential it needs to authenticate a future action: a key it generated on a SaaS during onboarding, a token returned by an OAuth exchange, a session cookie captured after a login, or a password it set up on a customer's behalf. All of them share the same danger — if they enter the model's context to be "remembered," they can be exfiltrated. The safe path below is identical for each: store it encrypted and per-tenant, and let the platform use it server-side.

Step 1 — Create a tenant user

Give each customer their own identity so their keys are isolated to them. You address that customer's vault through naive.forUser(customerId); nothing you store leaks across tenants.

const alice = await naive.users.create({ email: "alice@acme.com" });

Step 2 — Store the key (encrypted, per user)

Put the key in the vault under a descriptive name. It's envelope-encrypted with AWS KMS and scoped to that tenant user. put is idempotent on the key, so re-storing simply replaces it.

await naive.forUser(alice.id).vault.put("instantly.api_key", "key_xyz", {
  kind: "api_key",
});

The kind field (api_key, password, cookie, token, note, reference) is just a label for your own organization and UI — it doesn't change how the value is protected. Every value is envelope-encrypted with AWS KMS at rest, so you never hold plaintext keys in your own database, and because it's scoped to alice, no other tenant's agent can list or read it.

Step 3 — Lock it if the agent shouldn't read it

If the agent only needs the key's effect — not its value — store it locked. A locked entry can be used by the platform but never revealed back; a reveal returns forbidden.

await naive.forUser(alice.id).vault.put("stripe.restricted_key", "rk_live_...", {
  kind: "api_key",
  locked: true,
});

This is the safest default for autonomous calls: even a fully compromised prompt can't extract a key the agent was never able to read.

Step 4 — Reveal server-side only when a call needs it

When your backend actually needs the key to make a call, reveal it — over POST, so the secret returns in the body and never appears in a URL or query log.

const { value } = await naive.forUser(alice.id).vault.reveal("instantly.api_key");
// use `value` immediately for the outbound call; don't put it back in the prompt

Reveal at the moment of use and discard it — never round-trip a revealed secret back into the model's context. In practice this means your tool implementation (the server-side function the agent triggers) reveals the key, makes the outbound call, and returns only the result to the model — never the key itself. The agent sees "the email was sent," not the key that sent it.

Step 5 — Expire and rotate

For short-lived keys, set an expires_at when you store them; the entry drops out of listings and 404s on reveal once it passes. For long-lived keys, rotate on a schedule:

# re-wrap the encryption key (cheap); add ?regenerate_dek=true for a full re-encrypt
naive vault rotate instantly.api_key --user alice

Rotation here is an operation, not a migration: you don't redeploy your app or touch your own database, because the key never lived there in the first place.

How do you know it worked?

You can list a tenant user's entries at any time — values come back masked, so listing is safe to log:

naive vault list --user alice

If you stored an entry locked and then try to reveal it, you'll get a forbidden error — that's the system working as intended, confirming the agent genuinely cannot read the value back. An expired entry returns not-found on reveal and disappears from the list. Those two behaviors are the quickest way to confirm your keys are sealed the way you expect.

A note on what not to do

  • Don't put the key in the system prompt or a tool argument — it becomes exfiltratable and ends up in logs.
  • Don't share one key across tenants in an env var — one leak exposes everyone.
  • Don't reveal a key and then feed it back into the model "so it can use it" — that reintroduces the exact risk you removed.

Why this matters most in multi-tenant apps

If you're building a product where every customer has their own agent, the stakes on this change completely. A key in a shared prompt or a shared environment variable isn't your test credential leaking — it's one customer's production key exposed to another customer's agent, or to your logs. Per-tenant vaulting turns that from a design you have to police into a boundary the platform enforces: naive.forUser(alice.id) can only ever reach Alice's entries. Scoping, encryption, and locked-until-used are the difference between "we're careful" and "the platform blocks cross-tenant access by default" — and the latter is the only story that holds up when real customers and real keys are involved.

Where to go next

That's the whole safe path: create a user, store the key, lock it, reveal server-side, expire and rotate. For the reasoning behind the pattern and the multi-tenant context, see secrets management for AI agents and the multi-tenant playbook. The vault docs are the source of truth for kinds, expiry, locking, and rotation.

Frequently Asked Questions
How do I store an API key for an AI agent safely?+
Keep the key out of the prompt. Store it in a per-user encrypted vault and let the platform use it server-side when a call needs it. On Naïve you `put` the key under a tenant user, it's envelope-encrypted with AWS KMS, and you `reveal` it over POST only at the point of use — so the raw value never sits in the model's context or your logs.
Where should AI agent API keys be stored?+
Not in the system prompt, environment variables shared across tenants, or a plaintext database column. Store them in an encrypted, per-tenant secret store scoped to the individual customer. Naïve's vault scopes every entry to one tenant user, so a customer's agent can only reach that customer's keys.
How do I let an agent use an API key without reading it?+
Store the entry as `locked`. A locked vault entry can be written and used by the platform to complete a call, but a `reveal` request returns `forbidden`, so the agent never receives the raw key. This is ideal for autonomous API calls and logins where the agent needs the key's effect, not its value.
How do I rotate or expire a stored API key?+
Set an `expires_at` timestamp when you store a short-lived key; after it passes, the entry drops out of listings and returns not-found on reveal. For long-lived keys, `vault_rotate` re-wraps the encryption key cheaply and a full re-encrypt is available — so rotation is a single operation, not an app migration (per published docs).
Why is reveal a POST instead of a GET?+
Because GET requests and their query strings are captured in server logs, proxies, and browser history. Naïve's `reveal` is a POST so the decrypted secret travels in the response body, never in a URL — one small design choice that keeps keys out of the places logs accidentally collect them.
NT
Naïve Team

Building the agent-native backend.

Keep reading