Skip to main content

Secret References

Secret references let one secret's value embed the value of another secret. Instead of duplicating a connection string or rebuilding a URL by hand, you compose it from its parts:

DB_USER=app
DB_PASS=s3cr3t
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@db.internal:5432/app

When this environment is read, DATABASE_URL resolves to postgres://app:s3cr3t@db.internal:5432/app.

Syntax

TokenMeaning
${KEY}Reference another secret in the same environment
${env.KEY}Reference a secret in another environment of the same project, where env is the target environment slug
$${KEY}Escape — produces the literal text ${KEY} and is never treated as a reference

The secret key must match ^[A-Za-z_][A-Za-z0-9_]*$ and the environment slug must be a valid slug (^[a-z0-9]+(?:-[a-z0-9]+)*$). Tokens that don't match — for example ${ not-a-key } — are left untouched.

Same-environment reference

HOST=api.example.com
PORT=8443
ENDPOINT=https://${HOST}:${PORT}/v1

Cross-environment reference

Pull a value from another environment in the same project by prefixing the key with that environment's slug:

# in the "staging" environment
API_BASE=${prod.API_URL}

Here prod is the slug of the target environment. Cross-environment references require that you have read access to the referenced environment — see Security below.

note

Cross-project references are not supported. A reference can only point at the same project's environments.

Escaping

If you need a literal ${...} in a value (for example, a shell template that should be expanded later), double the dollar sign:

PROMPT=$${USER}@$${HOST}    # resolves to the literal: ${USER}@${HOST}

The escape is processed first, so $${KEY} never matches a reference token.

When references are resolved

References are stored verbatim — the raw value, tokens and all, is what lives encrypted at rest. Resolution happens only when a value is consumed:

Read pathResolves references?
GET /api/v1/secrets/.../{env} (CLI pull, run)Yes
GET /api/v1/secrets/.../{env}/{key} (single secret)Yes
Export to a .env file from the dashboardYes
Promote secrets to another environmentNo — token stored as-is
Copy secrets to another environment or projectNo — token stored as-is

Because promote and copy preserve the literal token, a reference like ${DB_HOST} keeps pointing at whatever DB_HOST is in the destination environment after it lands there. This is intentional: copying a composed DATABASE_URL to production should rebind to production's DB_HOST, not freeze staging's value.

References can nest: a resolved value may itself contain references, and those are expanded too (up to a depth limit). Cycles (ABA) and overly deep chains are detected; the offending token is left literal rather than looping forever.

The CLI (pull, run) needs no special handling — it receives already-resolved values from the API.

Security

Cross-environment references respect the caller's read access. They never let you read a value you couldn't already read directly:

  • A same-environment reference is always allowed — you already have access to that environment.
  • A cross-environment reference (${env.KEY}) is resolved only if you have read access to the referenced environment. For a user, that means an org owner/admin role or explicit project/environment access. A service token scoped to one environment cannot read another environment through a reference.

When a reference cannot be resolved — because access is denied, the environment doesn't exist, or the key isn't found — the token is left literal in the returned value and reported. It is never silently replaced with an empty string, and a value you can't access is never leaked.

Reference errors

Unresolved references are surfaced in the API response alongside the secrets, in a referenceErrors array. Each entry is the unresolved reference, formatted as env.key for cross-environment references or just key for same-environment ones:

{
"secrets": {
"API_BASE": "${prod.API_URL}"
},
"referenceErrors": ["prod.API_URL"],
"version": "..."
}

A token reported in referenceErrors keeps its literal ${...} form in the value, so you can see exactly what failed to resolve.

Examples

Compose a database URL from parts:

DB_USER=app
DB_PASS=s3cr3t
DB_HOST=db.internal
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:5432/app

Reuse a shared base URL across environments:

# production environment
API_URL=https://api.example.com

# staging environment
API_BASE=${prod.API_URL}

Keep a literal template untouched for a downstream tool:

LOG_FORMAT=[$${LEVEL}] $${MESSAGE}    # resolves to: [${LEVEL}] ${MESSAGE}