This guide covers day-to-day secret management: storing API tokens and credentials, rotating them safely, pruning old versions, and choosing scope.
Spring Voyage distinguishes three tiers of configuration so credentials live where they can be rotated, scoped, and audited independently:
| Tier | Location | Examples | Who sets it |
|---|---|---|---|
| Tier 1 — platform-deploy | Env / spring.env / startup config |
DB connection, Dapr wiring, GitHub App credentials (see Register your GitHub App); Slack OAuth credentials when persisted via spring connector slack install --write-env |
Ops team at deploy time |
| Tier 2 — tenant-default | Database | LLM runtime credentials (anthropic-oauth, anthropic-api-key, openai-api-key, google-api-key), tenant-wide observability / monitoring tokens |
Tenant admin post-deploy |
| Tier 3 — unit-override | Database | Per-unit variants of any tier-2 credential (a unit that calls a different Anthropic account than the tenant default) | Unit operator |
LLM provider credentials explicitly belong to tier 2, not tier 1 — they are workload credentials, not deployment identity. The platform reads them through the chain:
- Unit-scoped secret (if the caller has a unit in context)
- Tenant-scoped secret (the inheritance fall-through from unit scope, or the direct read when there is no unit context — e.g. the unit-create wizard fetching the model catalog)
When nothing resolves, the platform fails cleanly — the operator-facing error names the exact secret the resolver looked for ("no LLM credentials configured for this unit; set via spring secret --scope unit or configure tenant defaults at spring secret --scope tenant create <name> / the portal's Tenant defaults panel at /settings"). There is no environment-variable fallback: credentials must be set at tenant or unit scope. The private cloud build layers its own per-tenant resolver on top.
A secret is a named, scoped, versioned reference to sensitive material:
- Name — case-sensitive operator-chosen identifier (
openai-api-key,github-app-key). - Scope —
Unit,Tenant, orPlatform. Determines ownership and resolver visibility. - Version — monotonically-increasing integer. Rotation appends; prior versions survive until pruned.
- Origin —
PlatformOwned(platform holds the ciphertext) orExternalReference(pointer to externally-managed material, e.g. an Azure Key Vault secret id).
Plaintext enters exactly once (on create or rotate) and is never returned in any response, list entry, or log. The only path that surfaces plaintext is ISecretResolver.ResolveAsync, which runs server-side and is consumed by agents, connectors, and tool launchers.
Startup-time credentials live outside this registry. The GitHub App
GitHub__AppId/GitHub__PrivateKeyPempair is sourced fromspring.envbefore the registry is reachable. Each deployment registers its own GitHub App; see Register your GitHub App. If missing, the GitHub connector boots disabled; if malformed, the host refuses to start. Everything on this page covers runtime secrets the platform manages — the startup bootstrap pair is out of scope.
The Slack connector resolves its OAuth credentials per call through the chain tenant-secret → platform-secret → env-config. When spring connector slack install --write-env is used, the credentials live in Slack__OAuth__* env vars and are Tier-1. When --write-tenant-secrets is used, they live in the tenant-scoped registry and are Tier-2 — rotated, audited, and isolated per tenant exactly like any other tenant secret. --write-secrets (platform scope) sits between the two. Pick whichever scope matches how you want to rotate / audit the values.
- CLI —
spring secret. Seven verbs:create,list,get,rotate,versions,prune,delete. Every scope is reachable with--scope <scope> [--unit <name>]. Accepts--output json. This guide uses the CLI as the primary example. - HTTP API. Scope-keyed endpoints under
/api/v1/units/{id}/secrets,/api/v1/tenant/secrets,/api/v1/platform/secrets. Useful from CI runners or foreign services — one example is at the end of this guide. - Portal. Two surfaces: the Tenant defaults panel at
/settings(set / rotate tier-2 LLM credentials inherited by every unit — recommended first-run step) and the unit's Secrets tab (list, create, delete unit-scoped secrets with an inherited from tenant / set on unit badge). Rotation, version listing, and pruning are CLI-only.
Authenticate the CLI: spring auth token create --name "<label>" — the token is persisted to ~/.spring/config.json.
| Scope | Owner key | Use for |
|---|---|---|
Unit |
Unit name | Credentials belonging to one unit — connector tokens, per-unit LLM key |
Tenant |
Tenant id | Credentials shared across most units — tenant-wide observability token |
Platform |
platform (literal) |
Infra-owned keys — platform signing keys, webhook shared secrets |
Unit secrets are visible only within that unit. Tenant secrets are visible to every unit that asks by name (see inheritance). Platform is admin-only — units do not fall through to it.
Pick the narrowest scope that works. Promote to Tenant only when the same credential is genuinely shared; reserve Platform for the platform's own keys.
# Pass-through (platform holds ciphertext)
spring secret create \
--scope unit --unit engineering-team \
openai-api-key --value "sk-live-..."
# From file (useful for PEM keys with newlines)
spring secret create \
--scope unit --unit engineering-team \
github-app-private-key --from-file ./github-app.pem
# External reference (pointer to a customer-owned vault)
spring secret create \
--scope unit --unit engineering-team \
github-app-key --external-store-key "kv://prod/github-app-privatekey"
# Tenant-scoped (shared by every unit that reads it by name)
spring secret create --scope tenant observability-token --value "..."
# Platform-scoped (infra-owned; requires platform-admin authorization)
spring secret create --scope platform system-webhook-signing-key --value "..."The CLI prints name, scope, and timestamp on success — never the plaintext or backing key. Supply exactly one of --value, --from-file, or --external-store-key. The OSS default permits all writes; production deployments can plug in a secret-access policy to enforce RBAC.
# List (name, scope, createdAt — no plaintext or store key)
spring secret list --scope unit --unit engineering-team
spring secret list --scope tenant
spring secret list --scope platform
# Inspect one secret (metadata only; never plaintext)
spring secret get --scope unit --unit engineering-team openai-api-key
spring secret get --scope unit --unit engineering-team openai-api-key --version 1
# All retained versions
spring secret versions --scope unit --unit engineering-team openai-api-keyEach version row reports version, origin, createdAt, and isCurrent. The current version is resolved by default; callers can pin older versions by number.
spring secret rotate appends a new version without destroying prior versions. The registry atomically writes the replacement and echoes the new version number.
spring secret rotate \
--scope unit --unit engineering-team \
openai-api-key --value "sk-live-NEW..."
# → Secret 'openai-api-key' rotated (Unit); new version = 2.Use --output json to capture the version field in scripts. Rotation can flip origin (ExternalReference → PlatformOwned and vice versa); the registry records the transition for audit decorators.
Prior versions remain resolvable by version pin until pruned. If a pinned version does not exist the resolver returns NotFound — it never silently substitutes another version.
# Keep the 2 most-recent versions; reclaim backing-store slots for dropped PlatformOwned versions
spring secret prune \
--scope unit --unit engineering-team \
openai-api-key --keep 2
# → keep=2, versionsRemoved=3--keep must be >= 1; the current version is always retained. ExternalReference pruning never touches the external store. A Secrets:VersionRetention knob is reserved for a future scheduler; until then, prune explicitly.
spring secret delete --scope unit --unit engineering-team openai-api-keyRemoves every version. Platform-owned backing slots are reclaimed; external-reference pointers leave the external store untouched. A partial store-side failure leaves the registry row intact so the operation is safe to retry.
Spring Voyage has no first-class "environments" — production, staging, and dev are separate tenants (cloud) or separate deployments. Within a tenant, the only automatic cross-scope composition is unit → tenant inheritance:
- When a caller asks for
(Unit, engineering-team, some-name)and no unit-scoped row exists, the resolver falls through to(Tenant, <tenantId>, some-name). - Access policy is checked at both scopes; a denial at either returns
NotFound. - Unit-scoped entries always win — a unit overrides a tenant secret by registering the same name.
- Fall-through is gated by
Secrets:InheritTenantFromUnit(defaulttrue). - Tenant → Platform does not chain. Units cannot probe platform keys by name.
# Tenant default — every unit resolves "observability-token" by name
spring secret create --scope tenant observability-token --value "tenant-default-..."
# Unit override — wins for research-team; everyone else reads the tenant default
spring secret create --scope unit --unit research-team \
observability-token --value "research-team-override-..."The tier-2 resolver keys LLM credentials by provider and auth method. Match these names exactly:
| Secret name | Runtime/provider edge | Injected environment variable |
|---|---|---|
anthropic-oauth |
Claude Code → Anthropic | CLAUDE_CODE_OAUTH_TOKEN |
anthropic-api-key |
Spring Voyage Agent → Anthropic | ANTHROPIC_API_KEY |
openai-api-key |
Codex → OpenAI; Spring Voyage Agent → OpenAI | OPENAI_API_KEY |
google-api-key |
Gemini → Google; Spring Voyage Agent → Google | GOOGLE_API_KEY |
# Tenant default for Claude Code
spring secret create --scope tenant anthropic-oauth --value "<token from claude setup-token>"
# Tenant default for Spring Voyage Agent + Anthropic
spring secret create --scope tenant anthropic-api-key --value "sk-ant-..."
# Per-unit override (bills against a different Anthropic account)
spring secret create --scope unit --unit research-team \
anthropic-api-key --value "sk-ant-research-..."Via portal: Tenant defaults panel at /settings for tenant-wide credentials; unit's Secrets tab for per-unit overrides. The Secrets tab shows an "inherited from tenant" badge for transitively inherited secrets.
Anthropic has two Spring Voyage credential edges, and each edge has one auth method:
| Runtime/provider edge | Auth method | Secret name | When to use |
|---|---|---|---|
| Claude Code → Anthropic | OAuth token | anthropic-oauth |
Units running the Claude Code runtime and container image. Generate the token with claude setup-token. |
| Spring Voyage Agent → Anthropic | API key | anthropic-api-key |
Units where the platform-managed agent calls the Anthropic API through the Spring Voyage provider adapter. |
The names are intentionally separate. A Claude Code unit never reads anthropic-api-key, and a Spring Voyage Agent unit never reads anthropic-oauth.
Both the portal wizard (/units/create) and spring unit create accept the runtime credential inline — the lowest-friction onboarding path.
- Pick an execution runtime on Step 1. The wizard derives the required credential edge (Claude Code → Anthropic OAuth, Codex → OpenAI API key, Gemini → Google API key, Spring Voyage Agent → selected provider).
- If the credential is not configured, an inline input appears with a "Save as tenant default" checkbox. Unticked = unit-scoped secret; ticked = tenant-scoped secret (all future units inherit it).
- If a tenant default already exists, an Override button appears. Use it to set a per-unit override or rotate the tenant default.
- On blur the wizard validates the credential against the selected edge. On success the Model dropdown appears seeded from the account's catalog.
The wizard never shows existing plaintext; Override clears the input.
# Claude Code: unit-scoped OAuth token
spring unit create research-team \
--runtime claude-code \
--oauth-token-from-file ~/.secrets/claude-code-token.txt
# Spring Voyage Agent + Anthropic: tenant-default API key
spring unit create platform \
--runtime spring-voyage \
--model-provider anthropic \
--api-key "sk-ant-xyz" \
--save-as-tenant-default
# Rejected — Ollama needs no runtime credential
spring unit create local-dev --runtime spring-voyage --model-provider ollama --api-key "anything"
# → "Runtime 'spring-voyage' with provider 'ollama' does not require a credential."See the CLI reference for the inline-credential flag set and the rejection matrix.
The OSS contract stops at unit scope. There is no SecretScope.Agent; every agent inside a unit sees the unit's full secret set.
For per-agent isolation today: spin up a single-agent unit for the agent that needs its own keys. This reuses the unit as the isolation boundary.
This is a known limitation — please reach out if your use case requires per-agent secret isolation.
Use the HTTP API from CI runners or environments without the CLI installed.
# Equivalent to: spring secret create --scope unit --unit engineering-team openai-api-key --value sk-live-...
curl -sS -X POST "$SPRING_API_URL/api/v1/units/engineering-team/secrets" \
-H "Authorization: Bearer $SPRING_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "openai-api-key", "value": "sk-live-..."}'Other verbs follow the same pattern: GET (list / versions), POST (create), PUT (rotate), POST /prune?keep=<n>, DELETE. The HTTP API accepts the same verbs: GET (list / versions), POST (create), PUT (rotate), POST /prune?keep=<n>, DELETE.
- Name by consumer, not provider.
github-app-keybeatsapp-8743-private-key; the consumer's code stays stable across vendor changes. - Match names across scopes. A unit override must use the same name as the tenant default it shadows; mismatches silently fall through.
- Prune ahead of your rotation cadence. Match
--keepto how far back a pinned caller might legitimately still be resolving. - Prefer
--from-fileover--value. Reading from atmpfs-backed temp file keeps the plaintext out of shell history. - Pick the narrowest scope. Widening to tenant scope adds an access-policy probe on every unit resolve; don't pay that cost speculatively.
- Don't hand-edit the Dapr state store. Backing slots use AES-GCM with
"{tenantId}:{storeKey}"as associated data; a transplanted ciphertext breaks authentication. - Configure a durable AES key on every deployment, including local dev. The platform refuses to start without
SPRING_SECRETS_AES_KEY(env) orSecrets:AesKeyFile(mounted file). Generate a key withopenssl rand -base64 32.