Skip to content

SecretSpec 0.14: Secret References

SecretSpec keeps a secretspec.toml that declares what secrets an application needs, and resolves the values from a provider: your system keyring, 1Password, Vault, a .env file, and so on. Until now it stored every secret under a naming convention it controlled, secretspec/{project}/{profile}/{key}, and that convention was the only place it looked.

That works when SecretSpec created the secret. It does not when the secret already exists under a name something else chose: a db item in a 1Password vault, a myapp/config path at a Vault mount, an environment variable your platform already sets. To manage such a secret you had to copy its value into SecretSpec’s convention, leaving two copies to rotate, or leave it out of SecretSpec entirely.

SecretSpec 0.14 introduces ref. A secret can name one that already exists, by the store’s own coordinates, and SecretSpec reads and writes that secret in place:

[profiles.production]
DATABASE_URL = { description = "Postgres DSN", ref = { item = "db", field = "password" }, providers = ["prod_op"] }

DATABASE_URL now resolves from the password field of the 1Password item db. SecretSpec does not prepend a project or profile, and does not create a name of its own.

1Password will give you an address for that field: op://Production/db/password. The obvious design is to accept that string in the config and be done. We built that first and removed it.

A string like op://Production/db/password names the store and the secret at the same time, which ties the secret to 1Password. The same reference cannot then resolve from Vault in CI and 1Password on a laptop, cannot be redirected at a .env fixture for a test run without editing the manifest, and does not compose with a provider fallback chain, because the chain and the address disagree about where the secret is.

SecretSpec already decides which store to use, through providers, profiles, fallback chains, and the --provider override. A ref names only the secret and leaves the store to that existing machinery.

A ref is a table, not a URL. Each key names 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 required, because every store names its secrets somehow. item is the complete name: it replaces the whole convention path, with no project or profile and no folder prefix prepended. ref = { item = "GITHUB_PAT" } on the env provider reads the environment variable GITHUB_PAT and nothing else.

The other keys refine item for stores that have that structure. A .env key holds a single value, so field on a dotenv ref is not meaningful. A Vault KV entry is a map, so field is required. When a store has no equivalent for a coordinate it reports an error naming that coordinate, rather than reading a different secret:

GITHUB_TOKEN = { description = "GitHub token", ref = { item = "GITHUB_PAT", field = "x" }, providers = ["env"] }
Error: Provider operation failed: the env provider does not support the `field` coordinate. Drop `field` from the ref for `GITHUB_PAT`.

All eleven providers resolve refs, and each rejects the coordinates it cannot represent. A store whose secrets have no internal parts gets that rejection from shared code, without any per-provider work.

A ref supplies the name only. Which provider resolves it follows the normal provider resolution order: a --provider override, then the secret’s providers chain, then profile and global defaults. That is the same order every other secret uses.

Because the store is not part of 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 logs a warning and the chain continues:

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

Chain entries can also be inline scheme:// URIs, as above, with no [providers] alias declared first.

The --provider override redirects a referenced secret the same way it redirects a conventional one, so pointing a whole suite at a .env fixture needs no change to the manifest:

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

Reads and writes use the same coordinates. secretspec set and interactive check write to the referenced secret in place wherever the store supports writes:

Terminal window
$ secretspec set DATABASE_URL
# writes the `password` field of the 1Password item `db`, in place

1Password edits the field with op item edit and does not create items. Keyring, pass, dotenv, Bitwarden, Proton Pass, and LastPass write their refs as well. Vault, AWS Secrets Manager, and Google Secret Manager are read-only for refs and report that directly, rather than claiming the provider cannot write at all.

A ref also composes with generate. If the referenced secret does not exist yet, SecretSpec generates the value and writes it to the coordinates, so the first check populates the item everything else already reads.

check, run, and the SDKs now group secrets by store and fetch the groups concurrently instead of one store after another. Within a group, referenced secrets use the store’s bulk API where it has one (AWS BatchGetSecretValue, and the single Bitwarden, Proton Pass, and 1Password listings) and otherwise resolve concurrently, fetching each unique coordinate once. CLI authentication for 1Password, LastPass, and Proton Pass is probed once per account or session instead of once per provider instance.

Terminal window
cargo install secretspec

Three changes to be aware of:

  • A onepassword:// URI carrying an item path used to drop the path and target a vault literally named vault. Item paths, including pasted op://vault/item/field strings, now fail with an error that gives the ref table to write instead. Provider URIs are store addresses only.
  • ref is always a table. String and URI forms are rejected, with the same translation in the error.
  • Manifest validation now runs on every load. Rules that secretspec.toml documents (a required secret cannot have a default, generate needs a type, ref coordinates must be non-empty) are enforced on load rather than ignored. A manifest that violated one of them will now fail with a clear error.

See Secret References for the full model and the configuration reference for how each provider maps the coordinates. Questions or feedback? Join us on Discord.