Skip to content

Secret References

By default, SecretSpec owns the naming: it stores each secret under its own {project}/{profile}/{key} convention. A secret reference overrides that for one secret, naming a secret that already exists in the store and is managed outside SecretSpec. SecretSpec then reads (and writes) that existing secret in place, instead of a convention path it controls.

You declare a reference with the ref field, a table of provider-independent coordinates:

[profiles.production]
# The 1Password item "db", its "password" field
DATABASE_URL = { description = "Postgres DSN", ref = { item = "db", field = "password" }, providers = ["prod_vault"] }
# An existing environment variable
GITHUB_TOKEN = { description = "GitHub token", ref = { item = "GITHUB_PAT" }, providers = ["env"] }

Coordinates address a secret from the outside in

Section titled “Coordinates address a secret from the outside in”

A ref is not a store-specific address like op://vault/item/field. It is a set of provider-independent coordinates, each naming a level of structure that some stores have:

vault which container holds the item (1Password only)
└── item the store's own name for the secret (always required)
└── section a named group of fields (1Password only)
└── field one component inside the item (structured stores)
└── version which revision to read (GCSM only)

Only item is universal, because every store names its secrets somehow. item is the complete name, not a suffix: it replaces the entire convention path, so nothing is prepended.

# Reads the .env key TOTALLY_DIFFERENT_NAME, not secretspec/myapp/default/DATABASE_URL
DATABASE_URL = { description = "DB", ref = { item = "TOTALLY_DIFFERENT_NAME" }, providers = ["dotenv"] }

The other coordinates exist because some stores give a secret internal structure (field, section), nest it inside a container (vault), or keep revisions (version). A store that has no equivalent for a coordinate rejects it with an error naming the coordinate, rather than silently reading the wrong secret. The configuration reference documents exactly how each provider maps the coordinates.

A ref supplies naming only. It does not pin the secret to a particular store. Which provider actually resolves the coordinates follows the ordinary provider resolution order: a --provider override, then the secret’s providers chain, then the profile and global defaults.

This is the difference from pasting a store URL into your config. Because the store is not baked into the reference, the same ref works across providers. Each provider in a fallback chain is asked for the same coordinates, and one that cannot interpret them warns and the chain continues:

[profiles.production]
DATABASE_URL = { description = "Postgres DSN", ref = { item = "db", field = "password" }, providers = ["onepassword://Production", "keyring"] }

It also means --provider redirects reference secrets exactly like convention secrets, which makes test fixtures trivial: point every reference at a .env file without touching the manifest.

Terminal window
$ secretspec run --provider dotenv:.env.fixtures -- cargo test

Different coordinates per provider (0.19+)

Section titled “Different coordinates per provider (0.19+)”

The original ref remains useful when every provider understands one address. When endpoints organize the same logical secret differently, attach a template to each leaf alias and use refs only for exceptions:

[providers]
remote = { uri = "onepassword://Production", ref = { item = "{project}-{profile}", field = "{key}" } }
local = { uri = "dotenv://.env", ref = { item = "{key}" } }
legacy = "onepassword://Legacy"
[profiles.production]
API_KEY = { description = "API key", providers = ["remote", "local"], refs = { legacy = { item = "old-api-item", field = "token" } } }

SecretSpec resolves each endpoint independently: refs.<selected-alias> wins, then that alias’s template, then convention naming. This applies to primary and fallback reads, writes, and both sides of import; the legacy ref above can therefore describe an import source without adding it to the normal read route. Templates support {project}, {profile}, and {key} in every coordinate.

Scoped refs deliberately key on aliases, not resolved URIs. Literal provider URIs and bare provider names use convention naming because they have no alias identity. Cached route aliases cannot own a template or scoped ref; configure their individual leaf aliases instead. refs and legacy route-wide ref are mutually exclusive.

For profile inheritance, ref and refs (0.19+) are two forms of one address model. A profile that explicitly declares either form replaces the form inherited from [profiles.default]: refs can replace an inherited ref, and ref can replace inherited refs. A profile entry that declares neither keeps the inherited form. See Profiles: Switching reference models for an example.

  • item is required; field, vault, section, and version are optional and only accepted by stores that have that structure.
  • Reads and writes are symmetric: secretspec set and interactive check write through the coordinates in place wherever the store supports writes. Read-only stores fail with a clear error.
  • ref and every provider-scoped refs.<alias> value are tables. String and URI forms (ref = "op://vault/item/field") are rejected, with an error that spells out the equivalent table.
  • Secrets sharing identical coordinates and store are fetched once, and audit log events carry the coordinates.

See the configuration reference for the full specification: the coordinate table, how every provider interprets each coordinate, and the exact rules.