buildd
Concepts

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 usual

Secrets 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:

PurposeDelivered asDescription
anthropic_api_keyANTHROPIC_API_KEY in the worker subprocess envClaude API key
oauth_tokenCLAUDE_CODE_OAUTH_TOKEN in the worker subprocess envClaude Code OAuth token (seat-based billing)
mcp_credentialA ${label} substitution table — not the subprocess envCredentials 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:

PurposeValue must start with
oauth_tokensk-ant-oat
anthropic_api_keysk-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:

PurposeWhat label holds
mcp_credentialThe ${VAR} name referenced from .mcp.json server headers or URLs
mcp_connector_credentialThe connector's ID
inference_keyThe 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_KEY environment 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, encryptedValue

During 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 it

This 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_xxx

Returns 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_xxx

Via 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.

On this page