Skip to content

Secrets and Auth

Toolkits frequently need to authenticate to a third-party system of record — an internal API, a SaaS endpoint, a database proxy. This page explains how Trove handles that safely.

There are two distinct authentication questions in a toolkit, and it is worth keeping them separate:

  1. Is the caller who they say they are? (Claude → Trove) Already solved. The MCP client authenticates to Trove with Clerk OAuth 2.1 before any tool call reaches your toolkit. Your handler receives ctx.userId — a verified identity. You did not write any of that authentication logic; it is inherited for free.

  2. Can the toolkit reach the system of record? (your toolkit → third party) This is what this page covers. The two main patterns are static credentials (API keys, bearer tokens) and OAuth-brokered connections.


Most internal APIs and many SaaS services authenticate with a static credential: an API key, a bearer token, a service-account password. Trove stores these in an encrypted Secrets Vault that is separate from every database column the platform reads for normal operation.

The architecture in plain terms:

  • Secret values live in the vault. The vault is an encrypted store keyed per user and per toolkit. Values never appear in a D1 column, never pass through the GraphQL data layer, and are never included in your deployed bundle.
  • The registry holds references only. The platform records that your toolkit acme-orders has declared a secret named ORDERS_API_TOKEN. It does not record the value.
  • Decryption happens at invocation. When your handler calls ctx.secret("ORDERS_API_TOKEN"), the platform decrypts the value for that specific call, injects it into the isolate, and it exists only for the lifetime of that isolate — never written back, never logged.
  • Logs are redacted. Trove redacts all known secret values from log output before it is stored, so ctx.log(token) (please don’t do this, but if you do) will not expose the value.

Secrets are scoped to a deployed toolkit, so the first argument is the toolkit slug and the second is the secret name. Deploy the toolkit first, then set its secrets. The value comes from a flag (never an interactive prompt), so it can stay out of shell history:

Terminal window
# Inline (visible in shell history — fine for scratch tokens):
trove secret set acme-orders ORDERS_API_TOKEN --value "sk_live_…"
# From stdin (keeps the value out of argv/history):
printf '%s' "$ORDERS_TOKEN" | trove secret set acme-orders ORDERS_API_TOKEN --from-stdin
# From a file:
trove secret set acme-orders ORDERS_API_TOKEN --from-file ./token.txt

The value is sealed into the encrypted vault via the setServerSecret(serverId, name, value) mutation — it is never echoed, never logged, and never written to a D1 column.

Run the same trove secret set command again with the new value. It takes effect on the next invocation — no redeploy required, because the vault is decrypted fresh on every tool call.

Terminal window
trove secret ls acme-orders

The control plane does not yet expose secret references as a schema field, so trove secret ls reports the toolkit’s tool surface as the closest available reference and notes the gap. Secret values are never readable by design.


Every secret name your toolkit accesses must be declared in manifest.json secrets:

manifest.json
{
"secrets": ["ORDERS_API_TOKEN", "METRICS_READ_KEY"]
}

At runtime, ctx.secret(name) throws if name is not in the declared list — even if a value exists in the vault under that name. This prevents a toolkit from accidentally reading a secret intended for a different toolkit.

See manifest.json Reference for the full field spec.


Outbound HTTP from your toolkit is denied by default. Every hostname your handler calls via ctx.fetch must be declared in manifest.json egress:

manifest.json
{
"egress": ["orders.acme.com", "api.example.com"]
}

Trove enforces this allowlist at the network level. A request to any host not in the list — including a redirect that resolves to an unlisted host — is blocked with a network error, not a 403. Your handler receives a fetch failure.

This means a malicious dependency in your server.ts bundle cannot exfiltrate your secrets to an external host that is not in your allowlist, even if it intercepts a decrypted value inside the isolate.

egress lists public-internet hosts only. Even a host you explicitly allow is rejected if it resolves to a private, local, or reserved address — localhost / 127.0.0.0/8, the private ranges 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, the link-local range 169.254.0.0/16 (including the cloud-metadata IP 169.254.169.254), and the IPv6 equivalents. The platform checks the resolved address on every request and redirect, so a host that DNS-rebinds to an internal IP is still blocked. This is a server-side request forgery (SSRF) defense for a platform that runs untrusted code.

The practical consequence: a toolkit cannot reach your own machine — no localhost, no LAN or home network, no local files, no Mac apps. It runs in Trove’s cloud, not on your laptop. If you need local or private data, that’s a job for a source (which syncs on your Mac and collects data in) or a local stdio MCP server you run yourself — see What toolkits can and can’t reach.

Entries are matched on hostname only — no port, no path. "orders.acme.com" covers https://orders.acme.com/v1/... and https://orders.acme.com:8443/.... It does not cover https://api.orders.acme.com/... — list subdomains explicitly.


Combining secrets and egress — the full pattern

Section titled “Combining secrets and egress — the full pattern”

A typical toolkit-to-API pattern:

server.ts
async handler({ orderId }, ctx) {
// 1. Fetch the credential from the vault.
const token = await ctx.secret("ORDERS_API_TOKEN");
// 2. Call the external API via ctx.fetch (egress-controlled).
const res = await ctx.fetch(
`https://orders.acme.com/v1/${orderId}`,
{ headers: { authorization: `Bearer ${token}` } }
);
if (!res.ok) {
throw new ToolError(`Order ${orderId} not found`, { retryable: false });
}
return { text: `...` };
}

And the corresponding manifest:

manifest.json
{
"secrets": ["ORDERS_API_TOKEN"],
"egress": ["orders.acme.com"]
}

The two declarations are independent — you can have a secret with no egress (if you use the value to sign a request body, not an outbound call) and egress with no secret (if the API is public). In practice they usually travel together.


Some systems of record are themselves OAuth providers — Linear, Google Workspace, GitHub, Salesforce. For these, you don’t hold a static token; you need a user-authorized OAuth flow.

In a future release, Trove will broker OAuth connections on your behalf:

  1. You declare the provider and scopes in manifest.json:
    "oauth": { "provider": "linear", "scopes": ["read:issues", "write:comments"] }
  2. The user connects the account in the Trove web app or Mac app — same surface as existing sources.
  3. Trove manages the token lifecycle (refresh, rotation) and stores the resulting tokens in the vault as a managed secret.
  4. In your handler, ctx.secret("LINEAR_ACCESS_TOKEN") returns the current valid access token — no OAuth flow in your code.

Until OAuth-brokered connections are available, you can bridge the gap by manually fetching an OAuth access token, storing it as a static secret, and refreshing it with a separate script or cron job.


WhatWhere it livesWho can read it
Secret valueEncrypted vault (not D1)Platform only, at invocation time, for the owning user
Secret reference (name + vault key)Registry D1Control plane only — not the GraphQL data layer
Decrypted valueIsolate memoryYour handler only, for the lifetime of the call
Log outputPer-toolkit log storeYou (via trove logs), redacted of secret values

No secret value ever appears in a database column, a GraphQL response, a deployment bundle, or a log line.