Secrets & Credentials
How Buildd stores and delivers credentials to workers securely
Secrets & Credentials
Overview
Buildd can manage credentials on behalf of workers so they don't need local API keys. Secrets are encrypted at rest and delivered inline during task claiming — workers receive decrypted values over HTTPS without any intermediate storage.
How It Works
1. User stores a secret in Buildd (dashboard settings)
2. Secret is encrypted with AES-256-GCM and persisted to Postgres
3. Worker calls POST /api/workers/claim
4. Server decrypts matching secrets and includes them in the claim response
5. Runner injects secrets into the subprocess environment
6. Claude Code reads ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN as usualSecrets never touch disk on the runner. They exist only in the subprocess environment for the duration of the task.
Secret Purposes
Every secret has a purpose. The SecretPurpose union declares 12:
anthropic_api_key, oauth_token, codex_credential, claude_credential,
webhook_token, custom, mcp_credential, vercel_token, pushover,
notify_webhook, mcp_connector_credential, signing_key
The secrets.purpose column adds a 13th, inference_key, which is not in the
union.
POST /api/secrets accepts only eight of them:
anthropic_api_key, oauth_token, claude_credential, webhook_token,
custom, mcp_credential, vercel_token, inference_key
Anything else — including codex_credential, pushover, notify_webhook,
mcp_connector_credential and signing_key — is rejected with
400 Invalid purpose. Those purposes exist, but they are written by other
routes (connectors, JWKS rotation, the OAuth-token helper), not by this one.
The three purposes that reach a worker at claim time:
| Purpose | Delivered as | Description |
|---|---|---|
anthropic_api_key | ANTHROPIC_API_KEY in the worker subprocess env | Claude API key |
oauth_token | CLAUDE_CODE_OAUTH_TOKEN in the worker subprocess env | Claude Code OAuth token (seat-based billing) |
mcp_credential | A ${label} substitution table — not the subprocess env | Credentials for MCP server connections |
Required value prefixes
Two purposes have their value format enforced at create time, so a bad paste fails immediately instead of 401-ing hours later:
| Purpose | Value must start with |
|---|---|
oauth_token | sk-ant-oat |
anthropic_api_key | sk-ant-api |
A value that doesn't match is rejected with 400 Token must start with …. No
other purpose has a prefix rule — notably inference_key, where the provider is
identified by label and Anthropic (sk-ant-api…) and OpenRouter (sk-or-v1…)
keys share the purpose.
The value is also sanitized before the prefix check: it is always trimmed,
and for raw-string purposes a single pair of wrapping quotes is stripped. So
"sk-ant-oat01-…" is accepted and stored unquoted.
MCP credentials and the label field
label is a lookup key, not a guaranteed environment variable name, and what it
means depends on the purpose:
| Purpose | What label holds |
|---|---|
mcp_credential | The ${VAR} name referenced from .mcp.json server headers or URLs |
mcp_connector_credential | The connector's ID |
inference_key | The provider name, matched case-insensitively |
oauth_token (written by the OAuth helper) | A human-readable string, not a variable name |
For mcp_credential, the label is used for ${VAR} expansion inside
.mcp.json — it is not injected into the agent's environment. The runner
builds a separate expansion table, resolves the MCP server headers from it, and
passes only the already-resolved headers to the agent. The agent process never
sees the raw key as an env var, so an MCP server launched by other means cannot
read it.
Nothing validates that a label is a legal environment variable name. A label
like dispatch-api-key is stored happily and then never resolves as
${dispatch-api-key}, with no error at any point. Use upper-snake-case labels
(DISPATCH_API_KEY) for mcp_credential. The manage_secrets MCP action's own
error text suggests "buildd-api-key" as an example — do not copy that shape
for a credential you intend to reference as ${VAR}.
Encryption
All secret values are encrypted before storage using:
- Algorithm: AES-256-GCM (authenticated encryption)
- Key derivation: scrypt with a random 16-byte salt per secret
- Authentication: GCM auth tag prevents tampering
- Key source:
ENCRYPTION_KEYenvironment variable (minimum 32 characters)
The stored format is base64(salt + iv + authTag + ciphertext). Each secret gets its own random salt and IV, so identical plaintext values produce different ciphertext.
Scoping
Secrets are scoped to a team and optionally to an account:
secrets
id, teamId, accountId, workspaceId,
purpose, label, encryptedValueDuring task claiming, the server looks up secrets for the claiming account's team and includes matching anthropic_api_key, oauth_token, and mcp_credential entries in the response.
Runner Behavior
The runner uses server-managed secrets as a fallback — local credentials always take priority:
if runner has local ANTHROPIC_API_KEY → use it
else if claim response includes serverApiKey → inject itThis means existing runners with their own API keys continue working unchanged. Server-managed secrets only activate when the runner has no local credentials configured.
Data Model
-- Encrypted secrets store
secrets
id uuid PRIMARY KEY
team_id uuid REFERENCES teams(id)
account_id uuid REFERENCES accounts(id) -- nullable
workspace_id uuid REFERENCES workspaces(id) -- nullable
purpose text -- one of 13 values; see Secret Purposes above
label text -- lookup key; required for mcp_credential
encrypted_value text -- AES-256-GCM ciphertext
created_at timestamptz
updated_at timestamptz
-- Legacy: one secret per account + purpose + label (NULLS DISTINCT, so
-- team-wide rows with NULL account/label never collide)
UNIQUE (account_id, purpose, label)
-- Auth credentials are singletons per scope. Partial + NULLS NOT DISTINCT, so
-- team-wide rows DO collide instead of accumulating.
UNIQUE (team_id, account_id, workspace_id, purpose, label) NULLS NOT DISTINCT
WHERE purpose IN ('oauth_token', 'anthropic_api_key',
'codex_credential', 'claude_credential')The row also carries token-lifecycle and credential-health columns
(token_expires_at, last_refreshed_at, health_status,
consecutive_auth_failures, and others) that the expiry-alert and
credential-verification crons read.
API
The secrets API supports both session cookies and API key (Bearer bld_xxx) authentication.
Store a Secret
POST /api/secrets
Authorization: Bearer bld_xxx
Content-Type: application/json
{
"purpose": "mcp_credential",
"label": "DISPATCH_API_KEY",
"value": "dsp_xxx..."
}List Secrets (Metadata Only)
GET /api/secrets
Authorization: Bearer bld_xxxReturns secret records without values — encryptedValue is never exposed via API.
There is no PUT or PATCH on /api/secrets. Updating a secret is a repeat
POST, which replaces the row for that scope and resets its health status to
unknown.
Delete a Secret
DELETE /api/secrets?id=<secret-id>
Authorization: Bearer bld_xxxVia MCP
Agents can manage secrets programmatically through the manage_secrets MCP action:
# List all secrets (metadata only)
buildd action=manage_secrets params={ action: "list" }
# Store an MCP credential — label must be a valid ${VAR} name
buildd action=manage_secrets params={ action: "set", label: "DISPATCH_API_KEY", value: "dsp_xxx..." }
# Delete a secret
buildd action=manage_secrets params={ action: "delete", secretId: "uuid" }This enables agents to self-configure their MCP access — for example, a Chief of Staff role can store credentials for the dispatch and buildd MCP servers without requiring manual setup.