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" fieldDATABASE_URL = { description = "Postgres DSN", ref = { item = "db", field = "password" }, providers = ["prod_vault"] }
# An existing environment variableGITHUB_TOKEN = { description = "GitHub token", ref = { item = "GITHUB_PAT" }, providers = ["env"] }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 (supported stores 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_URLDATABASE_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.
References name, providers route
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.
$ secretspec run --provider dotenv:.env.fixtures -- cargo test
Terminal window
Different coordinates per provider
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.
How it works
itemis required;field,vault,section, andversionare optional and only accepted by stores that have that structure.- Reads and writes are symmetric:
secretspec setand interactivecheckwrite through the coordinates in place wherever the store supports writes. Read-only stores fail with a clear error. refand every provider-scopedrefs.<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.
Azure App Configuration (0.20+) native ref.item values name one App
Configuration key and remain read-only. An App Configuration value can
itself be a canonical Azure Key Vault reference; SecretSpec follows that
stored URI, including its optional Key Vault version. This is separate
from SecretSpec’s ref.version coordinate, which Azure Key Vault
accepts directly starting in 0.20.
Last updated Oct 08, 2026