← Blog
LaunchJuly 1, 2026Updated September 7, 20267 min read

Introducing Vault: write-only secrets your agent uses but never sees

The Vault primitive stores API keys and tokens an agent needs but must never read. Values are sealed once, injected at the network boundary, and never returned by any route.

Dennis Zax· CTO, Naïve

TL;DR

  • Vault is a write-only credential store: a secret travels once, on the create call, and no API route, SDK method, or CLI command ever returns it.
  • The agent handles a placeholder reference, not the value. The real secret is injected outside the sandbox, at the network boundary, so it never enters the model's context, the transcript, or your logs.
  • Three credential kinds: static_bearer and mcp_oauth for MCP servers (live today, matched by server URL), and env_var, bound to one exact destination host and substituted at egress (coming soon).
  • Credentials are immutable. Rotation is create-new then delete-old, so last_injected_at always points at exactly one secret.
  • Revocation is a delete. The value was never copied into the agent's environment, prompt, or transcript, so there is nothing else to chase; the agent's next call fails at the boundary.

Give an agent a real job and it needs real credentials: an API key for the payments provider, a token for the issue tracker, an OAuth grant for a customer's MCP server. The usual way to hand those over is to paste them into an environment variable or a system prompt. From that moment the secret lives in the model's context, in the transcript, in every log line that captured a tool call, and in the memory of whoever reads the run afterwards. A prompt injection or a careless echo $KEY is all it takes to ship it somewhere else.

Vault is our answer. It is the Trust primitive in Naïve's catalogue (/primitives/vault) and part of the identity layer of Vetta, the managed agent underneath. Its defining property is simple to state: the agent uses the secret, and never sees it.

Write-only, by contract

A vault holds credentials an agent needs but must never read. Secret values are accepted exactly once, on the call that creates the credential, sealed server-side, and then never returned by any read. Not masked. Not redacted. Not returned.

This is enforced at the schema level rather than by policy. The credential object the API returns has kind, key, host, mcp_server_url, placeholder_ref, expires_at, last_injected_at, and created_at. It has no value field. The SDK's credentials.list could not hand a secret back even if a handler tried, and the CLI has no reveal command and, as the docs put it, never will. If you need the raw value, read it from wherever you originally got it.

The write path is also fenced. Creating a vault or adding a credential requires the admin scope; listing vaults and credential metadata only needs agents:read. An agent-facing key can confirm that a credential exists without ever being able to add, remove, or read one. And POST /v1/vaults/{id}/credentials, the one route that accepts a secret, is deliberately withheld from Vetta's own MCP tool catalogue, so it is not something a model can be handed as a tool.

How injection works

The agent never handles the value. It handles a placeholder_ref, an opaque string of the form vetta_ref_.... The real secret is put back in outside the sandbox, at the network boundary, and only for the destination the credential was scoped to.

There are three credential kinds, each named by where the secret is allowed to go.

static_bearer is a fixed bearer token (an API key or a PAT) for an MCP server, keyed by mcp_server_url. When a session starts and the agent connects to a server whose URL matches one of the agent version's declared mcp_servers, the MCP connector adds the token on Naïve's side of the connection. The sandbox never receives it.

mcp_oauth is the same thing for MCP servers that use OAuth 2.0. It is injected the same way and refreshed automatically when a refresh block is supplied.

env_var is for CLIs, SDKs, and direct API calls that authenticate through an environment variable. Inside the sandbox the variable holds only the placeholder. When the agent runs something like curl https://api.payments.example.com -H "Authorization: Bearer $PAYMENTS_API_KEY", the egress proxy checks the request host against the credential's bound host. If they match, the placeholder is swapped for the real key on the way out. If they do not match, the placeholder is sent as-is and the secret never leaves.

That last rule is what makes prompt injection survivable. A tricked agent that tries to curl the placeholder to attacker.example.com sends an inert string. A credential can never be exfiltrated to a host it was not scoped to, and --host is required precisely because the injector never wildcards.

One honest caveat. The two MCP kinds are live today. The env_var kind depends on all sandbox HTTPS egress being pinned through Vetta's egress proxy, which is coming soon; until it lands, the API refuses env_var credentials with feature_not_configured rather than storing a secret under a guarantee it cannot yet keep. We would rather refuse a write than pretend.

Sealing a credential

One vault per identity is the usual shape. An identity is the persona an agent acts as, and a vault is bound to one persona (or left org-wide by omitting the identity). Values come from stdin by default, because an argv secret is visible in ps and lands in shell history.

vetta vault
vetta vault create --name "ava-vault" --identity idn_6sc5s97c5jt1ds84qngajv659p
 
# A static bearer token for an MCP server
printf '%s' "$TOKEN" | vetta vault set --vault vlt_... --mcp-bearer https://mcp.example.com/mcp
 
# An environment variable, bound to one exact destination host
printf '%s' "$PAYMENTS_KEY" | vetta vault set --vault vlt_... --env PAYMENTS_API_KEY --host api.payments.example.com
 
# Metadata only; no value is ever returned
vetta vault credentials --vault vlt_...

The same seven operations exist in the TypeScript SDK, and none of them reads a secret:

@usenaive-sdk/vetta
const vault = await client.vaults.create({ display_name: "ava-vault", identity_id: ava.id });
 
await client.vaults.credentials.create(vault.id, {
  kind: "static_bearer",
  key: "Task tracker",
  mcp_server_url: "https://mcp.example.com/mcp",
  value: process.env.TRACKER_TOKEN!, // travels once, sealed server-side
});
 
const { data } = await client.vaults.credentials.list(vault.id); // key, kind, last_injected_at; never a value

If you run your project from a naive.config.ts and naive up, vaults are declared alongside identities and agents. Credential values are { from_env } only, never a literal, and are read from your shell at apply time. Because a live value cannot be read back, naive up reconciles a credential's presence, not its value: a declared credential that already exists is unchanged, a missing one is created, and one you never declared is never touched.

naive.config.ts
vaults: [
  {
    name: "Ava's keys",
    identity: "Ava",
    credentials: [
      { kind: "static_bearer", key: "GitHub", mcp_server_url: "https://mcp.github.example/sse", value: { from_env: "GITHUB_TOKEN" } },
    ],
  },
],

Rotation and revocation

There is no update route for a credential, and that is the contract rather than an omission. Rotation is two records and two ids: set the new secret, then delete the old one.

Rotate
printf '%s' "$NEW_TOKEN" | vetta vault set --vault vlt_... --mcp-bearer https://mcp.example.com/mcp
vetta vault rm --vault vlt_... --credential vcr_old...

The reason is audit, not ceremony. Each credential is immutable, so last_injected_at is always attributable to exactly one value. During an incident the question you need answered is "which secret was in use at the moment of this call?", and an in-place update would make that unanswerable at exactly the wrong time. last_injected_at is also the signal that tells you a credential was used, and when, without telling you what it is.

Revocation is a delete. The value was never copied into the agent's environment, prompt, or transcript, so there is no second copy in the sandbox to chase; the vault was the only place it lived. Deleting a vault soft-deletes the record and hard-deletes its secrets; the values are destroyed, not archived, and any agent relying on that injection starts failing at the egress boundary on its next call. Pair this with a short expires_at (an RFC 3339 timestamp) and a token cleans itself up without anyone remembering to. For the broader pattern of cutting an agent off, see how to revoke AI agent access instantly.

At rest

Every value is protected with envelope encryption. A per-record data key encrypts the secret with AES-256-GCM, and that data key is itself wrapped by a key held in a managed key service, bound to your organization and to the identity that owns the vault. A vault maps to an identity and is referenced per session, so a credential is scoped to the persona that is allowed to act with it, and the wrapping key is bound to your organization and that identity.

Where it fits

Vault covers one specific need: secrets an agent must act with but must never read. Two neighbours cover the rest.

  • Connections hold third-party OAuth grants the identity has authorised. The agent connects to real services as its persona rather than through a shared bot token, and the tokens stay vaulted.
  • Approvals sit at the tool boundary. Every tool, including every MCP tool, carries an allow, ask, or deny policy, and MCP tools default to ask, so a newly exposed server tool never auto-runs even when its credential is present.

The combination is what lets an agent run unattended for months. Access is set up once by an operator with the admin scope, the agent acts under a policy that says what it may do, and nobody has to lend it a login. If you are embedding agents into your own product, this is also how you keep one customer's secrets away from another's: a vault per identity, one identity per customer, and the isolation comes from the platform rather than from your application code. We go deeper on that shape in building AI agents into your SaaS.

Get started

Read the vault page, seal one credential, and check last_injected_at after the first session. That is the whole loop: the agent did the work, and it still does not know the key.

FAQ

What is the Vault?
The Vault is Naïve's write-only credential store for agents. You seal an API key or token into a vault once, the agent references it by placeholder, and the platform injects the real value at the network boundary. No read path exists: every GET returns metadata only.
Can an agent read a secret back out of the Vault?
No. There is no reveal route in the API, no reveal method in the SDK, and no reveal command in the CLI. The credential schema has no value field, so a list call could not return one even if a handler tried. If you need the secret, read it from wherever you originally got it.
What stops a prompt-injected agent from leaking a key?
The agent only ever holds an opaque placeholder. An env_var credential is bound to one exact destination host; a request to any other host is sent with the inert placeholder, not the secret. MCP credentials are added on Naïve's side of the connection and never enter the sandbox.
How do I rotate a credential?
Create the new credential, then delete the old one. There is deliberately no update route: each credential is immutable, so last_injected_at stays attributable to exactly one value and an audit can answer which secret was in use at the moment of a given call.
How is the Vault encrypted at rest?
Each value is envelope-encrypted: a per-record data key encrypts the value with AES-256-GCM, and that data key is wrapped by a key held in a managed key service, bound to your organization and the identity that owns the vault.