This is the full developer documentation for SecretSpec
# Basic Usage
> The SecretSpec commands you will use most often
Once your project has a `secretspec.toml` file and you have selected a default provider, most day-to-day work uses a small set of commands.
## Check required secrets
[Section titled “Check required secrets”](#check-required-secrets)
Check that every required secret can be resolved. Missing values are shown without printing any secrets, and SecretSpec offers to set them interactively:
```bash
$ secretspec check
```
Use `secretspec check --no-prompt` in CI or other non-interactive environments. It exits with an error when a required secret is missing.
## Store or replace a value
[Section titled “Store or replace a value”](#store-or-replace-a-value)
Set a secret without putting its value in your shell history:
```bash
$ secretspec set API_KEY
Enter value for API_KEY (profile: development): ********
✓ Secret 'API_KEY' saved to keyring (profile: development)
```
Running `set` again replaces the stored value. The secret must already be declared in `secretspec.toml`.
## Read one value
[Section titled “Read one value”](#read-one-value)
Resolve and print a single secret:
```bash
$ secretspec get DATABASE_URL
postgresql://localhost/myapp
```
Caution
`get` prints the secret as plain text. Avoid using it in shared terminals, logs, or scripts. Use `run` or an SDK when an application needs the value.
## Run your application
[Section titled “Run your application”](#run-your-application)
Start a command with the resolved secrets available as environment variables:
```bash
$ secretspec run -- npm start
```
The `--` separates SecretSpec’s options from the command you want to run. SecretSpec stops before starting the command if a required secret is missing.
## Add a declaration
[Section titled “Add a declaration”](#add-a-declaration-018)
**New in version 0.18**
Declare a new secret without editing `secretspec.toml` by hand, then store its value:
```bash
$ secretspec add API_KEY --description "API access token"
$ secretspec set API_KEY
```
`add` changes only the declaration. It never asks for or stores the secret value.
## Delete stored values
[Section titled “Delete stored values”](#delete-stored-values-018)
**New in version 0.18**
Remove a stored value from its provider:
```bash
$ secretspec delete API_KEY
```
This leaves the declaration in `secretspec.toml`, so the project still records that it expects `API_KEY`. See the [CLI reference](/reference/cli/#delete-018) for deleting multiple values or using `--all`.
## Use another profile or provider
[Section titled “Use another profile or provider”](#use-another-profile-or-provider)
Your configured defaults apply automatically. Override them for one command with `--profile` or `--provider`:
```bash
$ secretspec check --profile production
$ secretspec run --provider dotenv://.env.test -- npm test
```
These options do not change your saved preferences.
## Next steps
[Section titled “Next steps”](#next-steps)
* See every option in the [CLI command reference](/reference/cli/)
* Learn how [profiles](/concepts/profiles/) separate environments
* Explore available [providers](/concepts/providers/)
# But I Use SOPS
> SOPS encrypts files. SecretSpec gives applications a portable contract for the secrets they need.
Whenever I show someone SecretSpec, I often hear the same response:
> But I use SOPS.
[SOPS](https://getsops.io/docs/) is good. It encrypts files so they can live in Git without exposing their plaintext values.
But SecretSpec solves a different problem: how applications declare, find, and consume secrets.
## How does your application use the secret?
[Section titled “How does your application use the secret?”](#how-does-your-application-use-the-secret)
Once you have encrypted `secrets.yaml`, how does your Python service consume it? What about your Go worker or Node.js app?
You still need to decrypt the file, inject its values, select the right file for each environment, validate required keys, and repeat that integration for every language.
And if you release the project as open source, that choice does not stay yours. With SOPS baked into the setup, everyone who runs or contributes to the project must adopt SOPS and its key management, whatever secrets tooling they already use.
SecretSpec starts at the other end. The project [declares what the application needs](/concepts/declarative/) without storing any values:
secretspec.toml
```toml
[project]
name = "payments"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "Postgres connection string" }
STRIPE_API_KEY = { description = "Stripe secret key" }
```
The same secret can come from a developer’s [system keyring](/providers/keyring/) or CI [environment variables](/providers/env/), while a more sensitive production environment resolves it from [Vault](/providers/vault/). Applications use the same declaration through nine SDKs for [Rust](/sdk/rust/), [Python](/sdk/python/), [Go](/sdk/go/), [Ruby](/sdk/ruby/), [Node.js/TypeScript](/sdk/nodejs/), [Haskell](/sdk/haskell/), [PHP](/sdk/php/), [C#](/sdk/csharp/), and [Swift (0.18+)](/sdk/swift/) without knowing the provider.
Encrypted files also make the key workflow a project-wide requirement. Adding a teammate means adding their key to `.sops.yaml` and re-encrypting every file; removing one means rekeying and rotating the affected secrets, since their key already saw the plaintext. SecretSpec leaves identity and access to the provider: onboarding to Vault or a cloud secrets manager is granting a role, and offboarding is revoking it.
SOPS may be enough today. As your team grows more sensitive to how secrets are handled, you may want Vault’s access policies and centralized audit trail. If applications know about SOPS, each one needs migrating. If they know only SecretSpec, you change the [provider configuration](/concepts/providers/); SDK calls and secret names stay the same.
The same resolver provides [profiles](/concepts/profiles/), [required-secret checks](/reference/configuration/#secret-variable-options), [per-secret provider routing and fallback](/concepts/providers/fallback/), [provider-native references](/concepts/references/), [temporary files](/reference/configuration/#as_path-option), and [metadata-only audit logs](/concepts/audit/). You build the integration once, not once per provider and language.
## Different layers, different jobs
[Section titled “Different layers, different jobs”](#different-layers-different-jobs)
SOPS protects a file. SecretSpec gives applications a provider-independent interface. The selected provider remains responsible for storage, encryption, identity, access control, and availability.
I wrote a fuller [SecretSpec comparison](/comparison/) showing exactly where SecretSpec ends, where providers begin, and which responsibilities belong to each layer.
## Where SecretSpec goes next
[Section titled “Where SecretSpec goes next”](#where-secretspec-goes-next)
Because applications talk to an interface instead of a file, the interface can grow without touching them. Three open proposals point where it is heading:
* [Project security requirements](https://github.com/cachix/secretspec/issues/188) would let a project declare the guarantees a provider must meet, such as encryption at rest or an audit trail, and reject providers that fall short.
* [Lease-aware refresh](https://github.com/cachix/secretspec/issues/11) would let running applications follow key rotation and short-lived credentials instead of restarting for a new value.
* The [SOPS provider](/providers/sops/) (0.17+) brings SOPS itself behind the same SDK interface, making your encrypted files one more place secrets can come from.
With that provider, perhaps “But I use SOPS” just needs two more words:
> But I use SOPS with SecretSpec.
If encrypted files fit your workflow, keep using SOPS. Just recognize the boundary: encryption at rest is not an application secrets interface.
# Claude Code Stores OAuth Tokens in Plaintext
> On Linux, Claude Code protects MCP OAuth credentials with file permissions—not encryption. It should let users choose a real secret store.
Claude Code’s [MCP documentation says authentication tokens are “stored securely”](https://code.claude.com/docs/en/mcp#authenticate-with-remote-mcp-servers). On Linux, that currently means plaintext JSON protected by file permissions.
I checked Claude Code 2.1.257 after authenticating to several remote MCP servers. The file `~/.claude/.credentials.json` had mode `0600`, as it should, but it also contained a top-level `mcpOAuth` object with the access tokens. Here is the shape of one Cloudflare entry, with every credential value redacted:
\~/.claude/.credentials.json
```json
{
"mcpOAuth": {
"cloudflare-observability|…": {
"accessToken": "",
"clientId": "",
"discoveryState": "",
"redirectUri": "",
"serverName": "cloudflare-observability",
"serverUrl": ""
}
}
}
```
This matches Anthropic’s [credential-management documentation](https://code.claude.com/docs/en/team#credential-management).
* **macOS:** uses the encrypted macOS Keychain, falling back to `~/.claude/.credentials.json` when the Keychain is unavailable.
* **Linux:** uses `~/.claude/.credentials.json` with mode `0600`.
* **Windows:** uses `%USERPROFILE%\.claude\.credentials.json`, inheriting the access controls of the user’s profile directory.
That is a much narrower claim than most people hear when a product says a credential is “stored securely.”
## OAuth did not solve secret storage
[Section titled “OAuth did not solve secret storage”](#oauth-did-not-solve-secret-storage)
The browser flow makes the secret easy to miss. Run `claude mcp login`, approve access in the browser, and return to a connected MCP server. Nobody manually created a token, copied it from a dashboard, or pasted it into a configuration file.
But Claude Code still received a credential. It must persist that credential if the connection is to survive a restart.
OAuth is valuable here. Claude Code can discover the authorization server, request specific scopes, complete the authorization-code exchange, refresh an access token, and revoke the grant. Anthropic’s [MCP documentation](https://code.claude.com/docs/en/mcp#authenticate-with-remote-mcp-servers) also lets users pin the scopes Claude Code requests.
What OAuth does not specify is a secure local vault.
API tokens can also be scoped, limited to particular resources, assigned an expiry, rotated, and revoked independently. OAuth standardizes delegation and renewal, while avoiding the copy-and-paste ceremony. Those are substantial benefits, but they do not turn the resulting bearer token into something that is safe to leave in plaintext.
| Property | OAuth credential | Scoped API token |
| ------------------------------- | ---------------- | ----------------- |
| Must be stored by the client | Yes | Yes |
| Can have limited permissions | Yes | Yes |
| Can expire | Yes | Yes |
| Can be revoked independently | Usually | Yes |
| Can be replayed if stolen | Yes | Yes |
| Standard interactive delegation | Yes | Provider-specific |
| Standard automatic renewal | Often | Usually external |
OAuth solves how Claude Code obtains and renews a delegated credential. Secret storage solves what happens to that credential between uses. They are separate concerns.
## Claude Code needs a credential-store interface
[Section titled “Claude Code needs a credential-store interface”](#claude-code-needs-a-credential-store-interface)
Claude Code should not decide that every Linux user’s MCP tokens belong in the same plaintext file. The persistence layer should be replaceable:
```text
Claude Code OAuth client
│
▼
credential-store interface
│
▼
SecretSpec TypeScript SDK
│
▼
user-selected SecretSpec provider
```
The right integration point is the [SecretSpec Node.js / TypeScript SDK](/sdk/nodejs/). It embeds the Rust resolver, so the TypeScript side does not need bespoke code for each backend. Claude Code could serialize one MCP OAuth credential per server and ask SecretSpec to load, save, or delete it using the provider the user or organization selected.
SecretSpec 0.20 has [33 provider integrations](/concepts/providers/#available-providers). They cover local keyrings, password managers, encrypted files, cloud secret managers, and deployment destinations. Providers declare their capabilities, so a credential store can require readable and writable storage while still using the same interface everywhere.
The current TypeScript SDK exposes SecretSpec’s provider-independent resolver. We would add the small `get`/`set`/`delete` credential-store surface Claude Code needs rather than reimplement 33 integrations in its codebase.
For example, set the [system keyring](/providers/keyring/) as the default provider in the local user configuration:
```bash
$ secretspec config global init --provider keyring --profile default
```
A team might instead require [OpenBao](/providers/openbao/) or a cloud secret manager. A headless workstation might use an [age-encrypted store](/providers/age/). The OAuth flow would stay exactly the same; only persistence would change.
> **What Codex does:** Codex makes MCP OAuth storage configurable. Its [configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference#mcp_oauth_credentials_store) documents `auto`, `file`, and `keyring` backends. Setting `mcp_oauth_credentials_store = "keyring"` selects the system keyring. This is not a general secret-provider interface, but it avoids making a plaintext credential file the only option on Linux.
> **Coming in SecretSpec 0.21:** We are working on [versioned resolver and provider IPC](https://github.com/cachix/secretspec/pull/362) for zero-dependency integrations. Applications will be able to use SecretSpec providers over a local protocol without embedding an SDK or provider code.
## Making open source software more secure
[Section titled “Making open source software more secure”](#making-open-source-software-more-secure)
We are working to make open source tools retrieve credentials from a user-selected secret store instead of copying them into another plaintext file. SecretSpec now provides [Git](/integrations/git/) and [Docker](/integrations/docker/) credential helpers, and we have proposed a generic, operation-scoped [secret resolver interface for Nix](https://github.com/NixOS/nix/pull/16339).
Because these projects are open source, we can inspect their credential boundaries and contribute safer ones upstream.
We cannot make the equivalent fix in Claude Code. Its [public repository](https://github.com/anthropics/claude-code) does not include the core CLI implementation, and its [license is all rights reserved](https://github.com/anthropics/claude-code/blob/main/LICENSE.md). We can support the [open request for secure, pluggable credential storage](https://github.com/anthropics/claude-code/issues/73582) and propose a SecretSpec TypeScript integration, but only Anthropic can change Claude Code’s MCP OAuth storage today.
That is one of the practical security benefits of open source: when a secret crosses the wrong boundary, users do not have to wait for the vendor to decide that the boundary matters.
# Secrets Don’t Belong in Config
> An audit of 445 NixOS modules shows the cost of making configuration and secrets share one interface.
Applications should not require passwords, API keys, or tokens in their configuration files.
Configuration describes behavior. It belongs in git, code review, bug reports, and developer machines.
A secret grants authority. It needs restricted access and independent rotation.
Putting both in one file couples different lifecycles and audiences. If rotating a password requires regenerating application configuration, the interface has coupled them too tightly.
## NixOS contains 110 workarounds for this
[Section titled “NixOS contains 110 workarounds for this”](#nixos-contains-110-workarounds-for-this)
We [audited all 445 NixOS modules that handle a real secret](https://github.com/NixOS/nixpkgs/issues/24288#issuecomment-5024009774) in nixpkgs at commit `141f212`, classifying each by where its secret value ends up.
| Where the secret value ends up | Modules | Share |
| ------------------------------------------ | ------: | ----: |
| Merged into a config file at runtime | 110 | 25% |
| Inlined into a config in `/nix/store` | 42 | 9% |
| Delivered as an environment variable | 161 | 36% |
| Left in a dedicated file opened by the app | 58 | 13% |
| Loaded through systemd credentials | 53 | 12% |
| Passed as a command-line argument | 19 | 4% |
| Classification uncertain | 2 | — |
The interesting number is 110. A quarter of the modules retrieve a secret safely, then copy it into configuration because that is the only interface the application accepts.
These modules use `envsubst`, `replace-secret`, `jq`, `yq`, `sed`, or custom code to assemble a restricted file at startup. The result can be secure, but every module now owns application-specific, security-sensitive glue just to combine two inputs that should have remained separate.
This is not unique to NixOS. The same workaround appears as an entrypoint script, Helm template, init container, or CI interpolation step on other platforms.
As a side note, 42 modules can inline secrets into the world-readable `/nix/store`. That direct security problem is tracked in [nixpkgs issue #24288](https://github.com/NixOS/nixpkgs/issues/24288). The 110 runtime mergers make the broader point: even when deployment authors avoid the leak, the missing separation still creates work.
## Give secrets their own interface
[Section titled “Give secrets their own interface”](#give-secrets-their-own-interface)
Applications should accept secret values through a dedicated runtime channel, such as:
* a `password_file` or `token_file` setting;
* a systemd credential;
* a narrowly scoped environment variable;
* or an external secret provider.
These mechanisms are not equally safe: environment variables can be inherited, arguments can appear in process listings, and files still need correct permissions. What separation does guarantee is that the deployer no longer has to manufacture a second, secret-bearing version of the configuration.
The principle is simple; implementing it across environments is not. Local development might use a system keyring, CI environment variables, and production 1Password or Vault. Without a shared abstraction, each environment needs its own naming, lookup, validation, and injection glue.
## How I got it wrong in Cachix
[Section titled “How I got it wrong in Cachix”](#how-i-got-it-wrong-in-cachix)
Cachix historically stored its auth token and per-cache signing keys in `~/.config/cachix/cachix.dhall`, alongside cache names and other configuration. It was convenient, but the file had to be treated as a secret even though much of it was ordinary configuration.
A typical file mixed them directly:
\~/.config/cachix/cachix.dhall
```dhall
{ authToken = "XXX-AUTH-TOKEN"
, binaryCaches =
[ { name = "mycache"
, secretKey = "XXX-SIGNING-KEY"
}
]
}
```
The cache name is configuration; the auth token and signing key are secrets. You could not share the cache configuration without also sharing credentials.
[devenv 2.2 separates the token through SecretSpec](https://devenv.sh/binary-caching/#setup-with-secretspec-recommended). The project declares `CACHIX_AUTH_TOKEN`, devenv resolves it from the configured provider, and the value is passed to Cachix without being added to devenv’s configuration.
[Cachix PR #737](https://github.com/cachix/cachix/pull/737) brings the same boundary into the client through the SecretSpec Haskell SDK. It resolves `CACHIX_AUTH_TOKEN` and `CACHIX_SIGNING_KEY` from SecretSpec and can store them in the user’s chosen provider instead of `cachix.dhall`. Existing environment variables and config files remain higher-priority fallbacks for compatibility. The PR is still open.
That is the problem SecretSpec is designed to solve: configuration declares the requirement, while each environment chooses where the value lives.
## Declare once, resolve anywhere
[Section titled “Declare once, resolve anywhere”](#declare-once-resolve-anywhere)
SecretSpec applies that separation by making `secretspec.toml` a declaration of what an application needs, without storing the values:
secretspec.toml
```toml
[project]
name = "myapp"
[profiles.production]
DATABASE_URL = { description = "Postgres connection string" }
STRIPE_API_KEY = { description = "Stripe secret key" }
```
[Providers](/concepts/providers/) decide where the values live. A developer can use the system keyring, CI can use environment variables, and production can use 1Password, Vault/OpenBao, or a cloud secret manager without changing the declaration.
An existing application can receive the resolved values at startup:
```bash
$ secretspec run -- ./myapp
```
Applications can also resolve them directly through the [SecretSpec SDKs](/sdk/overview/) for Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP, C#, and Swift (0.18+), all sharing the same resolver so behavior stays consistent across languages.
Providers own where secret values come from. SDKs give applications an idiomatic way to consume them. Configuration remains a shareable declaration of what is required.
## Making the boundary practical
[Section titled “Making the boundary practical”](#making-the-boundary-practical)
If you maintain an application, stop adding passwords and tokens to ordinary configuration schemas. Accept a file reference, credential, environment variable, or provider instead.
For NixOS, [SecretSpec issue #65](https://github.com/cachix/secretspec/issues/65) tracks how an official integration could declare and resolve secrets without per-module substitution glue.
Consistent secret handling across developer machines, CI, and production used to require infrastructure that only dedicated platform teams could build. A project of any size should be able to separate secrets from configuration without building its own secrets platform first.
# SecretSpec 0.12: Audit logs and coding agents
> Require a human-readable reason whenever a coding agent reaches for your secrets.
A coding agent reaches for the same secrets you do, but on its own initiative and many times a session: a read looks identical whether it came from you running a deploy or an agent exploring the codebase.
[SecretSpec 0.12](https://github.com/cachix/secretspec/releases/tag/v0.12.0 "SecretSpec 0.12 release") makes that access accountable. It ships three things:
* **Audit log** — every secret read and write is appended to a local, per-user JSONL log. On by default. Values are never recorded.
* **Reason-on-access** — secret access can require a human-readable reason, enforced for coding agents by default.
* **`secretspec audit` command** — filter and summarize the log, or pipe raw JSON Lines to `jq`.
Behavior change in 0.12
If you run SecretSpec inside a coding agent, secret access now **fails** until a reason is supplied. This is the new default (`require_reason = "agents"`). Opt out with `require_reason = false` in the `[project]` table. Existing providers and library callers keep working unchanged. See [Upgrading](#upgrading).
## The audit log
[Section titled “The audit log”](#the-audit-log)
Every secret read and write, from the CLI and the Rust SDK, is appended to a local log as [JSON Lines](https://jsonlines.org/), one event per line. Secret **values are never written**, only metadata: the secret name, the profile, the provider that served it (with any embedded credentials redacted), the outcome, the reason, and who was asking, including the detected coding agent.
```json
{
"v": 1,
"ts": "2026-06-04T17:04:00.893Z",
"action": "get",
"project": "my-app",
"profile": "production",
"key": "DATABASE_URL",
"provider": "keyring://",
"outcome": "found",
"reason": "deploy web frontend",
"actor": { "user": "alice", "agent": "claude-code", "is_agent": true },
"version": "0.12.0"
}
```
The log lives in your per-user state directory (`~/.local/state/secretspec/audit.log`) and is created readable only by you. Read it with any tool, or use the new `secretspec audit` command for filtering and a readable summary:
```bash
# Last 20 entries, formatted
$ secretspec audit -n 20
# Only `run` events for one project
$ secretspec audit --project my-app --action run
# Raw JSON Lines, piped to jq
$ secretspec audit --json | jq 'select(.outcome == "missing")'
```
It is configured in your **user-global config** (`~/.config/secretspec/config.toml`), not the project’s `secretspec.toml`, so a repository you clone can’t quietly turn off or redirect your audit log. The log is a single file capped at 1 MiB, a size-bounded recent record rather than permanent compliance history; forward it to a central system if you need that. To turn it off entirely:
\~/.config/secretspec/config.toml
```toml
[audit]
enabled = false
```
See [Audit Logging](/concepts/audit/) for the full record schema and options.
## Supplying a reason
[Section titled “Supplying a reason”](#supplying-a-reason)
When a coding agent like Claude Code reaches for a secret without a reason, the access is refused and the agent is told exactly what to do next:
```console
$ secretspec run -- npm test
Error: Accessing secrets requires a reason. Provide one with --reason
"", the SECRETSPEC_REASON environment
variable, or Secrets::with_reason() in the SDK. (Policy: require_reason in
[project] of secretspec.toml — defaults to "agents"; set it to false to
disable.)
```
Claude Code reads that message, states why it needs the secret, and retries:
```bash
$ secretspec run --reason "run the test suite before opening a PR" -- npm test
```
Both the refusal and the successful retry land in the audit log, so the reason is tied to the access. There are three ways to supply a reason:
| Source | Scope | Precedence |
| ------------------------ | ------------------ | ------------- |
| `--reason` flag | CLI | highest |
| `Secrets::with_reason()` | SDK | overrides env |
| `SECRETSPEC_REASON` | CLI + SDK + derive | lowest |
```bash
# CLI: the most explicit option, overrides the others
$ secretspec run --reason "deploying release 0.12" -- ./deploy.sh
```
```rust
// SDK: the programmatic equivalent of --reason
let secrets = Secrets::load(/* ... */)?.with_reason("nightly backup job");
```
```bash
# Env: lowest precedence, but honored everywhere
$ export SECRETSPEC_REASON="nightly backup job"
```
`SECRETSPEC_REASON` is resolved by `Secrets::load` / `load_from`, which means `secretspec-derive`-generated code and other library callers satisfy the policy and supply an audit reason **without any code changes**.
Whichever path you use, blank or whitespace-only reasons are ignored, so they can’t quietly satisfy the policy. Under the hood this is backed by a new `Provider::set_reason` trait method (a no-op by default), so existing providers keep working unchanged.
## Configuring when a reason is required
[Section titled “Configuring when a reason is required”](#configuring-when-a-reason-is-required)
The new `require_reason` policy in the `[project]` table controls when a reason is mandatory:
```toml
[project]
name = "my-app"
require_reason = "agents" # require it from agents (default), or true / false
```
* `"agents"` (the default): require a reason only when a coding agent is detected.
* `true`: require it from every caller.
* `false`: never require it.
Because the policy lives in `secretspec.toml` and is enforced by SecretSpec, it applies to everyone and every CI runner, and is inherited through `extends`. Coding agents are spotted by the [`detect-coding-agent`](https://crates.io/crates/detect-coding-agent) crate (Claude Code, Cursor, Codex, Gemini CLI, Copilot, and more); set `SECRETSPEC_AGENT` for a harness it doesn’t recognize.
## Upgrading
[Section titled “Upgrading”](#upgrading)
```bash
$ cargo install secretspec
```
Remember the new default: agents must pass a reason: set `require_reason = false` to opt out.
Questions or feedback? Join us on [Discord](https://discord.gg/naMgvexb6q).
# SecretSpec 0.13: SDKs for Python, Node.js, Go, Ruby, and Haskell
> Native Python, Node.js, Go, Ruby, and Haskell bindings over the same Rust resolver as the CLI.
SecretSpec separates *what* secrets an application needs, declared in `secretspec.toml`, from *where* the values live, a provider like your system keyring, 1Password, or Vault. Until now, reading those resolved secrets at runtime meant the CLI or the Rust SDK. If your service was written in Python or Go, you shelled out to `secretspec run` or reimplemented resolution yourself.
[SecretSpec 0.13](https://github.com/cachix/secretspec/releases/tag/v0.13.0 "SecretSpec 0.13 release") closes that gap. It ships native SDKs for five languages: Python, Node.js / TypeScript, Go, Ruby, and Haskell. Each resolves the exact secrets your manifest declares, through the same providers, profiles, fallback chains, and generators as the CLI, with no per-language configuration.
> This article uses the original `secretspec-ffi` name. The embedded ABI is named `libsecretspec` in SecretSpec 0.20+.
## Native bindings over one resolver
[Section titled “Native bindings over one resolver”](#native-bindings-over-one-resolver)
Every SDK is a thin client over the same Rust core that powers the CLI. No provider logic, profile resolution, chain fallback, `as_path` materialization, or secret generation lives in the binding. A provider added to SecretSpec works in every language the day it lands, and every SDK behaves identically.
The binding strategy is chosen per ecosystem:
* **Python**: a pyo3 extension, statically linked, shipped as a self-contained `cp39-abi3` wheel.
* **Node.js**: a napi-rs addon with prebuilt per-platform packages.
* **Ruby**: a native C extension (mkmf) with the resolver statically linked into a platform gem.
* **Go**: the `secretspec-ffi` C ABI loaded at runtime via [purego](https://github.com/ebitengine/purego) (no cgo).
* **Haskell**: the same C ABI, linked at build time through the Haskell FFI.
## The same three steps, in your language
[Section titled “The same three steps, in your language”](#the-same-three-steps-in-your-language)
Each SDK mirrors the vocabulary of the Rust derive crate: a builder that takes a provider, a profile, and an access reason, then `load()` to resolve, then a map of secrets you can read or export into the environment.
```python
# Python
from secretspec import SecretSpec
resolved = (
SecretSpec.builder()
.with_provider("keyring://")
.with_profile("production")
.with_reason("boot web app")
.load()
)
print(resolved.secrets["DATABASE_URL"].get) # value, or file path for as_path
resolved.set_as_env() # export into os.environ
```
```js
// Node.js / TypeScript
const { SecretSpec } = require('secretspec');
const resolved = SecretSpec.builder()
.withProvider('keyring://')
.withProfile('production')
.withReason('boot web app')
.load();
console.log(resolved.secrets.DATABASE_URL.get()); // value, or as_path file path
resolved.setAsEnv(); // export into process.env
```
```go
// Go
resolved, err := secretspec.New().
WithProvider("keyring://").
WithProfile("production").
WithReason("boot web app").
Load()
fmt.Println(resolved.Secrets["DATABASE_URL"].Get()) // value, or as_path file path
resolved.SetAsEnv() // export into the environment
```
```ruby
# Ruby
resolved = Secretspec::SecretSpec.builder
.with_provider("keyring://")
.with_profile("production")
.with_reason("boot web app")
.load
puts resolved.secrets["DATABASE_URL"].get # value, or as_path file path
resolved.set_as_env! # export into ENV
```
```haskell
-- Haskell
resolved <-
S.load
( S.builder
& S.withProvider "keyring://"
& S.withProfile "production"
& S.withReason "boot web app"
)
S.setAsEnv resolved -- export into the environment
```
Across all of them, `load()` resolves every declared secret, a missing required secret raises a typed `MissingRequiredError`, and `as_path` secrets come back as a readable file path with a cleanup that removes the backing temp file. The access reason feeds the same audit log and `require_reason` policy from [0.12](/blog/secretspec-0-12-audit-logs-and-coding-agents/), so a Go service is as accountable as the CLI.
## Write your own binding
[Section titled “Write your own binding”](#write-your-own-binding)
Under all five SDKs sits a new crate, `secretspec-ffi`: a small, versioned C ABI for resolving secrets. If we do not ship your language yet, you can bind to it directly. It also exposes the public Rust building blocks the SDKs share, `Secrets::resolve()` and `Secrets::report()`, so a Rust program reaches the same value-carrying and value-free entry points.
## Typed secrets, one schema for every language
[Section titled “Typed secrets, one schema for every language”](#typed-secrets-one-schema-for-every-language)
`secretspec.toml` already knows the shape of your secrets, so 0.13 can hand that shape to your type system. `secretspec schema` emits a JSON Schema for your manifest, the union of all profiles or one profile with `--profile`. Pipe it through [quicktype](https://quicktype.io) to generate idiomatic typed classes in any language, then populate them from each SDK’s `fields()` map:
```bash
$ secretspec schema | quicktype -s schema --top-level SecretSpec --lang python -o secrets_gen.py
```
```python
typed = Secrets.from_dict(resolved.fields())
print(typed.database_url) # typed str
```
One schema drives every language’s type system, with no hand-written emitter per language.
## Install
[Section titled “Install”](#install)
```bash
$ pip install secretspec # Python
$ npm install secretspec # Node.js / TypeScript
$ gem install secretspec # Ruby
$ go get github.com/cachix/secretspec/secretspec-go # Go
```
For Haskell, add `secretspec` from Hackage to your `build-depends`. The CLI and Rust SDK upgrade as usual:
```bash
$ cargo install secretspec
```
See the [SDK overview](/sdk/overview/) for the per-language guides. Questions or feedback? Join us on [Discord](https://discord.gg/naMgvexb6q).
# SecretSpec 0.14: Secret references
> A secret can now point at one that already exists in a provider's store, by the store's own coordinates, instead of a name SecretSpec picks.
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](https://github.com/cachix/secretspec/releases/tag/v0.14.0 "SecretSpec 0.14 release") 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:
```toml
[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.
## Why not just paste the address
[Section titled “Why not just paste the address”](#why-not-just-paste-the-address)
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.
## Coordinates
[Section titled “Coordinates”](#coordinates)
A `ref` is a table, not a URL. Each key names a level of structure that some stores have:
```plaintext
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:
```toml
GITHUB_TOKEN = { description = "GitHub token", ref = { item = "GITHUB_PAT", field = "x" }, providers = ["env"] }
```
```text
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.
## References name, providers route
[Section titled “References name, providers route”](#references-name-providers-route)
A `ref` supplies the name only. Which provider resolves it follows the normal [provider resolution order](/concepts/providers/fallback/): 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:
```toml
[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:
```bash
$ secretspec run --provider dotenv:.env.fixtures -- cargo test
```
## Writing through a ref
[Section titled “Writing through a ref”](#writing-through-a-ref)
Reads and writes use the same coordinates. `secretspec set` and interactive `check` write to the referenced secret in place wherever the store supports writes:
```bash
$ 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.
## Faster resolution
[Section titled “Faster resolution”](#faster-resolution)
`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.
## Upgrading
[Section titled “Upgrading”](#upgrading)
```bash
$ 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](/concepts/references/) for the full model and the [configuration reference](/reference/configuration/#secret-references) for how each provider maps the coordinates. Questions or feedback? Join us on [Discord](https://discord.gg/naMgvexb6q).
# SecretSpec 0.15: Provider credentials, Azure Key Vault / Gopass, and PHP SDK
> Authenticate providers from another secret store, use Azure Key Vault or Gopass, export secrets for CI, and resolve them from PHP.
[SecretSpec 0.15](https://github.com/cachix/secretspec/releases/tag/v0.15.0 "SecretSpec 0.15 release") ships:
* **[Provider credentials](/concepts/providers/#provider-credentials)** — authenticate one secret provider with credentials stored in another, without exporting them to the application environment.
* **[Azure Key Vault](/providers/akv/)** — store and resolve secrets with service-principal, Azure CLI, managed-identity, or AKS workload-identity authentication.
* **[Gopass](/providers/gopass/)** — use a GPG-encrypted, git-synchronized password store, including multi-user and multi-store setups.
* **[PHP SDK](/sdk/php/)** — use the shared SecretSpec resolver from PHP-FPM, Laravel, Symfony, and CLI applications through a native extension or `ext-ffi`.
* **[AWS creation guardrails](/providers/awssm/)** — set a customer-managed KMS key and required tags when SecretSpec creates an AWS Secrets Manager secret.
* **[`secretspec export`](/reference/cli/#export)** — resolve secrets without launching a command, with shell, dotenv, JSON, and GitHub Actions output.
* **[Provider and resolution fixes](/concepts/providers/)** — ordered lazy fallback chains, early `ref` validation, correctly merged profile overrides, stable output, and broader Node.js Linux compatibility.
## Credentials for the secret store
[Section titled “Credentials for the secret store”](#credentials-for-the-secret-store)
Suppose [Bitwarden Secrets Manager](/providers/bws/) holds an application’s secrets, but its machine access token is kept in the user’s [OS keyring](/providers/keyring/). Declare the relationship on the provider alias:
secretspec.toml
```toml
[providers]
keyring = "keyring://"
[providers.bws]
uri = "bws://a9230ec4-5507-4870-b8b5-b3f500587e4c"
[providers.bws.credentials]
access_token = "keyring"
```
Before SecretSpec connects to Bitwarden, it reads `access_token` from the keyring at the normal `{project}/{profile}/access_token` address. The active [profile](/concepts/profiles/) is part of that address, so production and development can authenticate as different machines without changing the alias.
When a credential already has a provider-native address, use a `ref`. Here a [Vault AppRole](/providers/vault/#approle-authentication) is kept as two fields of one [1Password item](/providers/onepassword/#use-existing-secrets):
secretspec.toml
```toml
[providers.vault_prod]
uri = "vault://secret/myapp?auth=approle"
[providers.vault_prod.credentials]
role_id.provider = "onepassword"
role_id.ref.vault = "Infra"
role_id.ref.item = "vault-approle"
role_id.ref.field = "role_id"
secret_id.provider = "onepassword"
secret_id.ref.vault = "Infra"
secret_id.ref.item = "vault-approle"
secret_id.ref.field = "secret_id"
```
The credential source uses the same [`ref` coordinates](/concepts/references/) as application secrets. The difference is where the value goes: SecretSpec hands it directly to the destination provider in memory. It is not added to the environment of a process started by [`secretspec run`](/reference/cli/#run).
Provider credential names are semantic and checked before a source is opened. Bitwarden accepts `access_token`; Vault accepts `token`, `role_id`, and `secret_id`; 1Password accepts `service_account_token`; Azure Key Vault accepts `tenant_id`, `client_id`, and `client_secret`. A configured credential is authoritative, while a provider’s usual environment fallback remains available when no credential source is declared.
Credential chains deliberately stop after one hop. The store containing a provider credential cannot itself depend on another provider credential. This keeps the bootstrap path finite and makes dependency mistakes fail before any store is contacted.
## Log in once, without an environment variable
[Section titled “Log in once, without an environment variable”](#log-in-once-without-an-environment-variable)
The new [`config provider login`](/reference/cli/#config-provider-login) command prompts for every credential an alias declares and writes it to the configured source:
```console
$ secretspec config provider login bws
Enter access_token for provider 'bws' (source: keyring): ****
✓ stored access_token in keyring at my-app/default/access_token
```
A user-level alias and its credential source can also be declared entirely with [`config provider add`](/reference/cli/#config-global-provider-add):
```bash
$ secretspec config provider add bws "bws://project-uuid" \
--credential access_token=keyring
$ secretspec config provider login bws
```
Credentials are fetched once per invocation and profile, then reused for every secret routed through that alias. Each credential read, and each value stored by `login`, gets an [audit event](/concepts/audit/) marked with the semantic credential name and source store. As with every SecretSpec audit event, the credential value is never recorded.
See [Provider Credentials](/concepts/providers/#provider-credentials) for the full configuration and resolution rules.
## Azure Key Vault
[Section titled “Azure Key Vault”](#azure-key-vault)
Azure Key Vault joins the provider list with the `akv://` scheme:
```bash
# Use service-principal credentials, or the current Azure CLI session
$ secretspec run --provider akv://myvault -- npm start
# Use the platform's managed identity
$ secretspec check --provider akv://myvault?auth=managed_identity
# Use AKS workload identity federation
$ secretspec run --provider akv://myvault?auth=workload_identity -- ./deploy
```
The default authentication mode first looks for the `tenant_id`, `client_id`, and `client_secret` provider credentials introduced above, then their `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET` environment fallbacks. If none are present, it uses the signed-in Azure CLI or Azure Developer CLI session. A partial service principal is an error, rather than a reason to silently switch identities.
That makes a service principal straightforward to keep in the system keyring:
secretspec.toml
```toml
[providers.azure]
uri = "akv://myvault"
[providers.azure.credentials]
tenant_id = "keyring"
client_id = "keyring"
client_secret = "keyring"
```
```bash
$ secretspec config provider login azure
$ secretspec run --provider azure -- ./deploy
```
Sovereign clouds can use either a complete vault hostname or an explicit DNS suffix such as `akv://myvault?suffix=vault.azure.cn`.
Azure restricts secret names to letters, digits, and hyphens and compares them case-insensitively. SecretSpec encodes the project, profile, and key as lowercase, unpadded Base32 components. The encoding keeps names that differ by case or punctuation distinct instead of letting Azure collapse them onto the same secret. Existing Azure secrets can be addressed with a read-only `ref`.
See the [Azure Key Vault provider guide](/providers/akv/) for authentication, naming, references, and required permissions.
## Gopass joins the local providers
[Section titled “Gopass joins the local providers”](#gopass-joins-the-local-providers)
The new [`gopass://` provider](/providers/gopass/) reads and writes through the `gopass` CLI. Gopass builds on the Unix [`pass` provider](/providers/pass/) with multi-user and multi-store support while keeping entries GPG-encrypted and synchronized through git.
Once Gopass is installed and its password store is initialized, select it like any other provider:
```bash
$ secretspec set DATABASE_URL --provider gopass
$ secretspec run --provider gopass -- npm start
```
By default, entries live under `secretspec/{project}/{profile}/{key}`. A custom URI can change that layout, including omitting `{project}` to share secrets between repositories:
\~/.config/secretspec/config.toml
```toml
[defaults.providers]
shared = "gopass://secretspec/shared/{profile}/{key}"
```
An existing Gopass entry can also be addressed directly with a [`ref`](/concepts/references/), including the mount-point prefix used by a multi-store setup. See the [Gopass provider guide](/providers/gopass/) for installation, shared-store configuration, references, and current limitations.
## PHP joins the SDKs
[Section titled “PHP joins the SDKs”](#php-joins-the-sdks)
The new `cachix/secretspec` Composer package brings the shared resolver to PHP:
```bash
$ composer require cachix/secretspec
```
```php
withProfile('production')
->withReason('boot web app')
->load();
echo $resolved->secrets['DATABASE_URL']->get();
$resolved->setAsEnv();
```
It offers two native backends behind the same PHP API. The recommended native extension embeds the resolver and works under PHP-FPM without `ffi.enable`, like `ext-redis`. An `ext-ffi` fallback loads the shared resolver at runtime for CLI tools and local development. Both use the same Rust core as the CLI and the other language SDKs, so profiles, providers, fallback chains, generators, [`as_path`](/reference/configuration/#as_path-option), [audit reasons](/reference/configuration/#requiring-a-reason-for-secret-access), and typed missing-secret errors behave the same way.
`setAsEnv()` updates `getenv()`, `$_ENV`, and `$_SERVER`, which lets Laravel’s `env()` helper and Symfony’s `%env(...)%` processors consume resolved secrets during application boot. See the [PHP SDK guide](/sdk/php/) for installation and framework examples.
## AWS creation guardrails
[Section titled “AWS creation guardrails”](#aws-creation-guardrails)
AWS accounts often require a customer-managed KMS key or specific tags in the same `CreateSecret` request. The [AWS Secrets Manager provider](/providers/awssm/) now accepts both on its URI:
secretspec.toml
```toml
[providers]
prod = "awssm://prod@us-east-1?kms_key_id=alias/my-key&tag.team=platform&tag.env=prod"
```
`kms_key_id` and repeatable `tag.NAME=VALUE` parameters are applied only when SecretSpec creates a secret. Updating an existing secret does not alter the key or tags it was created with. This supports tag-on-create SCP and IAM guardrails without turning routine secret updates into infrastructure changes.
## Export secrets for shells, tools, and CI
[Section titled “Export secrets for shells, tools, and CI”](#export-secrets-for-shells-tools-and-ci)
The new `export` command resolves every secret for the active profile without starting another process. Its default output can be evaluated by a POSIX shell:
```bash
$ eval "$(secretspec export --profile production)"
```
Use `--format dotenv` to write dotenv syntax or `--format json` to pass the resolved values to another tool:
```console
$ secretspec export --profile production --format json
{
"DATABASE_URL": "postgresql://prod.example.com/mydb"
}
```
GitHub and Forgejo Actions can use `--format gha`. SecretSpec masks every value in the runner log and appends it to `$GITHUB_ENV`, making the secrets available to later steps and third-party actions:
```yaml
- name: Export secrets
run: secretspec export --profile production --format gha
- name: Deploy
run: ./deploy
```
Like non-interactive [`check`](/reference/cli/#check), `export` never prompts and exits non-zero when a required secret is missing, so it can gate a CI job. Export attempts are also recorded in the [audit log](/concepts/audit/). See the [`export` CLI reference](/reference/cli/#export) for every format and option.
## Provider and resolution fixes
[Section titled “Provider and resolution fixes”](#provider-and-resolution-fixes)
0.15 also tightens the behavior around profiles and fallback chains:
* [Provider chains](/concepts/providers/fallback/) are now walked strictly in order and resolved lazily. An undefined alias or unreachable fallback is skipped with a warning only when a read reaches it, so a later working provider can still answer.
* Chain entries accept aliases, bare provider names such as [`keyring`](/providers/keyring/), shorthand such as [`dotenv:.env`](/providers/dotenv/), and complete provider URIs.
* A single destination provider rejects unsupported [`ref` coordinates](/concepts/references/) before contacting the store. Multi-provider chains still validate each destination as they reach it, because an earlier store may support coordinates a later one does not.
* [Profile overrides](/concepts/profiles/) inherit the base secret’s `description` and generation `type`. Validation now uses the effective merged secret while still catching real conflicts, such as combining [`generate`](/concepts/generation/) with a profile default.
* `run` passes non-UTF-8 environment variables through to the child untouched, and command output that previously depended on map order is now stable.
* Prebuilt [Node.js addons](/sdk/nodejs/) now target glibc 2.28 and statically include libdbus, restoring support for Amazon Linux 2023, RHEL 8/9, and similar distributions.
## Upgrading
[Section titled “Upgrading”](#upgrading)
```bash
$ cargo install secretspec
```
Existing providers retain their conventional environment authentication when an alias does not declare credentials. Provider credentials are opt-in, and credential dependency chains are limited to one hop.
See the [full changelog](https://github.com/cachix/secretspec/blob/main/CHANGELOG.md) for every change and fix in this release.
Questions or feedback? Join us on [Discord](https://discord.gg/naMgvexb6q).
# SecretSpec 0.16: Composed secrets, Infisical, and C# SDK
> Derive secrets from other declared values, use Infisical Cloud or self-hosted, and resolve secrets natively from .NET.
[SecretSpec 0.16](https://github.com/cachix/secretspec/releases/tag/v0.16.0 "SecretSpec 0.16 release") ships:
* **[Composed secrets](/concepts/composed-secrets/)** — derive a read-only value, such as a connection string, from other secrets declared in the manifest.
* **[Infisical](/providers/infisical/)** — read and write secrets in Infisical Cloud or a self-hosted instance, with Universal Auth, access-token, and provider-credential authentication.
* **[C# SDK](/sdk/csharp/)** — resolve the same manifests from .NET through the shared native resolver, distributed as the `Cachix.SecretSpec` NuGet package.
## Composed secrets
[Section titled “Composed secrets”](#composed-secrets)
Applications often need a connection string while secret stores work better with its independently rotated parts. SecretSpec can now keep those parts separate and assemble the application-facing value when it resolves the manifest:
secretspec.toml
```toml
[profiles.default]
DB_USER = { description = "Database user" }
DB_PASSWORD = { description = "Database password" }
DB_HOST = { description = "Database host" }
DATABASE_URL = {
description = "PostgreSQL connection string",
composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app"
}
```
`DB_USER`, `DB_PASSWORD`, and `DB_HOST` still come from their configured providers. `DATABASE_URL` is assembled in memory and behaves like any other resolved secret in the CLI and SDKs. Compositions are read-only, may build on other compositions, and are checked for missing references and cycles before resolution.
See [Composed Secrets](/concepts/composed-secrets/) for optional values, escaping, profile inheritance, and validation rules.
## Infisical
[Section titled “Infisical”](#infisical)
The new `infisical://` provider works with Infisical Cloud, its EU service, and self-hosted instances. Point SecretSpec at an Infisical project and authenticate with Universal Auth:
```bash
$ export INFISICAL_CLIENT_ID=...
$ export INFISICAL_CLIENT_SECRET=...
$ secretspec run \
--provider "infisical://app.infisical.com/7e2f1a4c-...?env=prod" \
-- npm start
```
Access tokens are also supported. Credentials can come from environment variables or SecretSpec’s [provider credentials](/concepts/providers/#provider-credentials), allowing, for example, an Infisical machine identity to be kept in the system keyring:
secretspec.toml
```toml
[providers.infisical]
uri = "infisical://app.infisical.com/7e2f1a4c-..."
[providers.infisical.credentials]
client_id = "keyring"
client_secret = "keyring"
```
By default, the active SecretSpec profile also names the Infisical environment. A `production` profile therefore reads from the `production` environment, while `?env=` can select a different one. The provider supports normal SecretSpec reads and writes, as well as references to existing Infisical secrets and versions.
See the [Infisical provider guide](/providers/infisical/) for self-hosting, authentication, paths, references, and permissions.
## C# SDK
[Section titled “C# SDK”](#c-sdk)
The `Cachix.SecretSpec` NuGet package brings the shared SecretSpec resolver to .NET 8:
```bash
$ dotnet add package Cachix.SecretSpec
```
```csharp
using Cachix.SecretSpec;
using var resolved = SecretSpec.Builder()
.WithProvider("keyring://")
.WithProfile("production")
.WithReason("boot web app")
.Load();
Console.WriteLine(resolved.Secrets["DATABASE_URL"].Get());
resolved.SetAsEnv();
```
It uses the same resolver as the CLI and other language SDKs, so profiles, providers, fallback chains, references, generators, audit reasons, and composed secrets work consistently in .NET. Native resolver builds are included in the NuGet package, with no separate SecretSpec CLI installation required.
See the [C# SDK guide](/sdk/csharp/) for supported platforms, ASP.NET Core integration, preflight reports, error handling, and typed access.
## Upgrading
[Section titled “Upgrading”](#upgrading)
```bash
$ cargo install secretspec
```
All three additions are opt-in: existing manifests and provider configurations continue to work unchanged. Add `composed` when a value should be derived, select an `infisical://` provider to use Infisical, or install `Cachix.SecretSpec` in a .NET application.
See the [full changelog](https://github.com/cachix/secretspec/blob/main/CHANGELOG.md) for every change in this release.
Questions or feedback? Join us on [Discord](https://discord.gg/naMgvexb6q).
# SecretSpec 0.17: Scopes, secrets caching, SOPS, age, and systemd credentials
> Resolve only the secrets a service needs, cache remote secrets safely, and use age-encrypted files or native systemd credentials alongside new providers.
[SecretSpec 0.17](https://github.com/cachix/secretspec/releases/tag/v0.17.0 "SecretSpec 0.17 release") ships:
* **[Scopes](/concepts/scopes/)**: let each service or task resolve only its declared subset of a profile.
* **[Secrets caching](/concepts/providers/caching/)**: cache a slow fallback route in a local secret store with bounded freshness and explicit invalidation.
* **[Cross-secret validation](#cross-secret-validation)**: declare alternative or mutually exclusive credentials instead of marking every value independently required or optional.
* **[GitHub and Forgejo Actions](#github-and-forgejo-actions)**: resolve a profile in one workflow step and expose its secrets to the steps that follow.
* **[New providers](#new-providers)**: now with 20 providers, use [SOPS](/providers/sops/), [age](/providers/age/), [KeePass KDBX](/providers/kdbx/), [OpenBao](/providers/openbao/), [Scaleway Secret Manager](/providers/scaleway/), and [systemd credentials](/providers/systemd-credential/) through the same SecretSpec interface, with JWT/OIDC authentication for [Vault](/providers/vault/) and [OpenBao](/providers/openbao/).
## Scopes
[Section titled “Scopes”](#scopes)
A profile describes how secrets resolve for an environment. A [scope](/concepts/scopes/) now describes which of those secrets one consumer may receive:
secretspec.toml
```toml
[profiles.default]
DATABASE_URL = { description = "Database" }
API_KEY = { description = "API key" }
QUEUE_TOKEN = { description = "Queue token" }
[scopes.api]
secrets = ["DATABASE_URL", "API_KEY"]
[scopes.worker]
secrets = ["DATABASE_URL", "QUEUE_TOKEN"]
```
```bash
$ secretspec run --scope api -- ./api
$ secretspec run --scope worker -- ./worker
```
Composed secrets may still read hidden dependencies to build a visible value, but those inputs are not exposed to the child. The same scope selection is available to `check`, `export`, and the [SDK builders](/sdk/overview/). Scopes minimize secret delivery; they are not an authorization boundary when the child itself holds provider credentials.
Carrying the selected scope through resolver requests and results required a breaking change to [`secretspec-ffi`](https://github.com/cachix/secretspec/tree/main/libsecretspec), which is named `libsecretspec` in SecretSpec 0.20+. All [SecretSpec SDKs](/sdk/overview/) have been updated for 0.17 to support scopes, so applications should upgrade their SDK package and bundled native resolver together.
## Secrets caching
[Section titled “Secrets caching”](#secrets-caching)
Many cloud providers take long enough to resolve a secret that their latency becomes part of every development command.
A single **1Password** lookup can take **roughly one second**.
SecretSpec providers implement `get_many` so a backend can resolve several values together. Relatively few secret stores and CLIs expose a true bulk-read operation, however, so many providers still have to perform separate lookups.
Waiting on a remote service or its CLI every time makes `check`, `run`, and application startup feel slow, especially as a project grows.
A provider alias can now combine its authoritative fallback route with a local cache:
secretspec.toml
```toml
[providers]
vault = "vault://vault.example.com:8200/secret"
local = "keyring://secretspec/cache/{project}/{profile}/{key}"
fast_vault = {
fallback = ["vault"],
cache = { provider = "local", max_age = "8h" }
}
[profiles.default.defaults]
providers = ["fast_vault"]
```
Fresh entries avoid contacting the remote provider. A miss or expired entry falls through to [Vault](/providers/vault/) and refreshes the cache; writes update the authoritative provider first and then refresh or invalidate its cached copy. Route changes, reference changes, and writes that bypass the cached alias also invalidate the entry.
The cache is a real copy of the secret, so SecretSpec requires a distinct store that it can delete from and records ownership before changing an entry. `secretspec cache clear [NAME]` forces the next read back through the authoritative route.
[Vault](/providers/vault/) and [OpenBao](/providers/openbao/) KV v2 caches are the only providers that handle `max_age` as server-side expiry properly.
None of SecretSpec’s current local providers has strong native support for expiry. They can remove an expired entry the next time SecretSpec sees it, but cannot ensure the local copy disappears at its deadline if SecretSpec never runs again.
Our planned [FactorSeal](https://github.com/domenkozar/factorseal) provider in [Future work](#future-work) is intended to close that gap with an explicit API for credential eviction among the other goals.
## Cross-secret validation
[Section titled “Cross-secret validation”](#cross-secret-validation)
Profiles can now express credential alternatives directly:
secretspec.toml
```toml
[profiles.default]
PASSWORD = { description = "Password", required = { at_least_one = "auth" } }
ACCESS_TOKEN = { description = "Token", required = { at_least_one = "auth" } }
GITHUB_TOKEN = { description = "GitHub token", required = { exactly_one = "github_auth" } }
GITHUB_APP_KEY = { description = "GitHub App private key", required = { exactly_one = "github_auth" } }
```
The `auth` group accepts a password, an access token, or both. The `github_auth` group requires exactly one credential and rejects configurations that provide both the token and the app key.
## New providers
[Section titled “New providers”](#new-providers)
**[SOPS](/providers/sops/)** brings the encrypted-file workflow from our recent [SOPS comparison](/blog/but-i-use-sops/) behind SecretSpec’s provider-independent CLI and SDKs. SecretSpec delegates encryption and decryption to the installed SOPS CLI, so existing SOPS key services and `.sops.yaml` creation rules remain in control.
The provider reads and writes YAML, JSON, dotenv, and INI files, supports a single shared file or `{project}` / `{profile}` path templates, and can source sensitive SOPS inputs such as age keys or cloud credentials through provider credentials.
secretspec.toml
```toml
[providers]
sops = "sops://secrets/{project}/{profile}.enc.yaml"
[profiles.production.defaults]
providers = ["sops"]
```
**[age](/providers/age/)** offers a smaller encrypted-file setup. It stores a dotenv-style secret set for one or more age recipients, including hybrid post-quantum recipients.
**[KeePass KDBX](/providers/kdbx/)** reads KDBX 3 and 4 databases and writes KDBX 4, with master passwords sourced from another provider rather than embedded in the URI.
**[OpenBao](/providers/openbao/)** gets its own `openbao://` identity and `BAO_*` configuration while sharing compatible KV, token, AppRole, and JWT mechanics with [Vault](/providers/vault/). Both Vault and OpenBao can now exchange a JWT for a short-lived token, including an OIDC token minted automatically in GitHub Actions and Forgejo Actions with `id-token: write`.
**[Scaleway Secret Manager](/providers/scaleway/)** adds regional, project-aware cloud storage and read-only references to existing secrets and revisions.
**[systemd credentials](/providers/systemd-credential/)** is a read-only provider that resolves values from the current service’s `$CREDENTIALS_DIRECTORY`, including credentials used to bootstrap another provider.
## GitHub and Forgejo Actions
[Section titled “GitHub and Forgejo Actions”](#github-and-forgejo-actions)
Alongside 0.17, the new [`cachix/secretspec-action`](https://github.com/cachix/secretspec-action) installs SecretSpec, resolves the selected profile, masks every value in the runner log, and adds the secrets to the environment of later job steps:
```yaml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: cachix/secretspec-action@main
with:
profile: production
scope: api
- run: ./deploy.sh
```
A missing required secret fails the action, so the same step also checks that the deployment environment is complete. See the [GitHub Actions guide](/ci/github-actions/) for provider selection and tokenless [Vault](/providers/vault/) or [OpenBao](/providers/openbao/) authentication through the runner’s OIDC identity.
## Upgrading
[Section titled “Upgrading”](#upgrading)
[SecretSpec 0.17](https://github.com/cachix/secretspec/releases/tag/v0.17.0) also brings:
* **[Bitwarden Secrets Manager](/providers/bws/)** — now uses the separately installed official `bws` CLI instead of linking its SDK.
* **Non-interactive setup** — `secretspec config global init --provider ... --profile ...` configures defaults without prompts.
* **Windows packages** — for the Python, Ruby, and PHP SDKs.
* **Clearer status output** — plus more controlled concurrency and retry behavior for Vault and OpenBao.
```bash
$ cargo install secretspec
```
See the [full changelog](https://github.com/cachix/secretspec/blob/main/CHANGELOG.md) for every change in this release.
## Future work
[Section titled “Future work”](#future-work)
The next work brings more control to local secret access:
* **[GUI confirmation dialogs](https://github.com/cachix/secretspec/pull/122)** — approve or deny a secret request in a native prompt instead of requiring a terminal interaction.
* **A [Passbolt provider](https://github.com/cachix/secretspec/pull/127) (0.19+)** — bring Passbolt’s open-source, collaboration-focused credential manager behind the same SecretSpec interface for cloud and self-hosted teams.
* **A [Bitwarden Password Manager provider](https://github.com/cachix/secretspec/pull/166)** — resolve regular Bitwarden vault items, separately from the Bitwarden Secrets Manager provider already available in SecretSpec.
* **A JVM SDK** — work is underway to bring the shared SecretSpec resolver to Java, Kotlin, and other JVM languages.
* **A [FactorSeal](https://github.com/domenkozar/factorseal) provider** — we have started work on a new Linux provider built around mandatory TPM-backed storage and secure defaults. FactorSeal also provides an explicit API for credential expiry, which is crucial for the caching work in this release: local copies can carry a defined eviction deadline instead of living without a retention policy. Still in development.
Questions or feedback? Join us on [Discord](https://discord.gg/naMgvexb6q).
# SecretSpec 0.18: Secret lifecycle, Bitwarden, Keeper, AWS Parameter Store, and Swift
> Declare, discover, migrate, and delete secrets from the CLI; use four new providers; and resolve the same manifests from Swift.
[SecretSpec 0.18](https://github.com/cachix/secretspec/releases/tag/v0.18.0 "SecretSpec 0.18 release") ships:
* **[Secret lifecycle commands](#secret-lifecycle-commands)**: add declarations, delete stored values, and move secrets between providers without leaving the source copy behind.
* **[Provider-backed discovery](#provider-backed-discovery)**: initialize a manifest from an age file, an AWS Parameter Store hierarchy, or a Bitwarden collection without writing any values to it.
* **[Four new providers](#four-new-providers)**: use [Bitwarden Password Manager](/providers/bw/), [Keeper Secrets Manager](/providers/keeper/), [AWS Systems Manager Parameter Store](/providers/awsps/), and [Dashlane](/providers/dashlane/) through the same CLI and SDK interface.
* **[Swift SDK](#swift-sdk)**: resolve manifests natively on macOS through a checksummed XCFramework that includes the shared Rust resolver.
* **[Vault and OpenBao authentication](#vault-and-openbao-authentication)**: use custom AppRole and JWT mounts, AppRoles without SecretID binding, and server-configured default JWT roles.
## We have a new logo!
[Section titled “We have a new logo!”](#we-have-a-new-logo)

## Secret lifecycle commands
[Section titled “Secret lifecycle commands”](#secret-lifecycle-commands)
SecretSpec has always kept the declaration in `secretspec.toml` separate from the stored value. 0.18 brings both sides of that lifecycle into the CLI.
[`secretspec add`](/reference/cli/#add-018) adds a declaration to the selected profile while preserving the manifest’s comments, formatting, and unrelated tables:
```console
$ secretspec add STRIPE_API_KEY --description "Stripe API access token"
✓ Added secret 'STRIPE_API_KEY' to profile 'default' in secretspec.toml
Set its value with: secretspec set STRIPE_API_KEY --profile default
$ secretspec set STRIPE_API_KEY
Enter value for STRIPE_API_KEY: ********
✓ Secret 'STRIPE_API_KEY' saved to keyring (profile: default)
```
`add` never asks for or stores the value. The declaration can be reviewed and committed before each developer or deployment supplies its own value.
[`secretspec delete`](/reference/cli/#delete-018) does the inverse on the storage side: it removes a value without changing the declaration. The next `check` therefore reports the secret as missing instead of quietly removing the application’s requirement:
```console
$ secretspec delete STRIPE_API_KEY
Deleted 'STRIPE_API_KEY'
Deleted 1 secret value; 0 already absent
```
Deletion is idempotent, invalidates an associated cache entry, and follows the same primary-write-provider routing as `set`. `delete --all` requires an interactive confirmation, or an explicit `--yes` in non-interactive use.
Provider migrations can now remove each source value after proving the move succeeded:
```bash
$ secretspec import dotenv:~/.config/payments/.env --delete-source
```
[`import --delete-source`](/reference/cli/#import) reads the destination back and compares it with the source before deleting anything. An identical value already at the destination is safe to remove from the source; a conflicting value leaves the source intact. SecretSpec also rejects a source without deletion support before writing the destination and recognizes equivalent provider spellings as the same store, so a migration cannot delete the value it just wrote through another alias.
Together these commands keep the distinction explicit: `add` changes what the application declares, `set` and `delete` change one environment’s stored value, and `import --delete-source` moves that value between stores.
## Provider-backed discovery
[Section titled “Provider-backed discovery”](#provider-backed-discovery)
The first SecretSpec command in an existing project is often `secretspec init --from .env`. In 0.18, [`init --from`](/reference/cli/#init) accepts every provider that can discover declarations, including age, AWS Parameter Store, and Bitwarden Password Manager.
Hierarchical stores also receive an explicit project and profile so SecretSpec looks only inside the namespace the new manifest will use:
```console
$ secretspec init \
--from 'awsps://production@us-east-1?template=/{profile}/{project}/{key}' \
--project payments \
--profile production
✓ Created secretspec.toml with 12 secrets
```
For a password-manager vault, scope discovery to the collection and item type that belong to the application:
```console
$ secretspec init --from 'bw://Acme%20Inc@dev-secrets?type=login'
✓ Created secretspec.toml with 8 secrets
```
Discovery writes names and generated descriptions, never secret values. After reviewing the manifest, keep the discovered provider as the profile’s source or use `secretspec import` to copy the now-declared values somewhere else.
## Four new providers
[Section titled “Four new providers”](#four-new-providers)
0.18 brings SecretSpec to 24 providers, with four additions spanning personal password managers, machine-oriented vaults, and cloud parameter storage.
**[Bitwarden Password Manager](/providers/bw/)** is separate from the existing Bitwarden Secrets Manager provider. The new `bw://` provider uses the official `bw` CLI to read and write regular vault items: logins, secure notes, cards, identities, and SSH keys. It can address organizations and collections by name or ID, restrict a provider to one item type or field, discover declarations, and point [`ref`](/concepts/references/) secrets at existing items. A `?server=` guard verifies that the CLI is logged into the expected self-hosted instance instead of silently reading the wrong vault.
**[Keeper Secrets Manager](/providers/keeper/)** uses Keeper’s official Rust SDK, so it does not need a separate CLI. A `keeper://FOLDER_UID` provider reads, writes, batches, and deletes convention records shared with a KSM application; refs can select an existing record and field. Its client configuration can come from `KSM_CONFIG`, a protected configuration file, or SecretSpec [provider credentials](/concepts/providers/#provider-credentials).
**[AWS Systems Manager Parameter Store](/providers/awsps/)** stores every value as a KMS-encrypted `SecureString`. The `awsps://` provider uses the standard AWS credential and region chains and supports shared-config profiles, hierarchy prefixes, complete `{project}` / `{profile}` / `{key}` templates, customer-managed KMS keys, and parameter tiers. Refs can select an existing parameter by name, version, label, or ARN; unversioned name refs are writable, while pinned revisions remain read-only. Its bounded hierarchy discovery uses `GetParametersByPath` without decrypting values.
**[Dashlane](/providers/dashlane/)** reads secrets, secure notes, and logins through the `dcli` CLI. It is intentionally read-only because `dcli` cannot create or edit vault items. A ref can address an existing item by title or identifier and select one of its fields. CI can provide `DASHLANE_SERVICE_DEVICE_KEYS` directly or source the same `service_device_keys` input from another SecretSpec provider.
A project can route different secrets through any combination of them:
secretspec.toml
```toml
[providers]
team_vault = "bw://Acme%20Inc@dev-secrets"
keeper_ci = "keeper://SHARED_FOLDER_UID"
parameters = "awsps://production@us-east-1?prefix=/platform"
dashlane_notes = "dashlane://note"
```
Provider choice still stays outside application code. The CLI and every SDK resolve the same declaration regardless of which of these aliases supplies a value.
## Swift SDK
[Section titled “Swift SDK”](#swift-sdk)
The new [Swift SDK](/sdk/swift/) brings the shared SecretSpec resolver to macOS 12 or later on Intel and Apple silicon. Add the repository as a Swift package:
```swift
dependencies: [
.package(
url: "https://github.com/cachix/secretspec",
from: "0.18.0"
),
]
```
Then use the same builder vocabulary as the other SDKs:
```swift
import SecretSpec
let resolved = try SecretSpec.builder()
.withProfile("production")
.withScope("api")
.withReason("boot web app")
.load()
defer { try? resolved.close() }
print(resolved.secrets["DATABASE_URL"]?.get() ?? "")
try resolved.setAsEnvironment()
```
The SDK exposes fluent and one-shot resolution, typed failures, value-free preflight reports, scopes, provenance, environment export, and JSON input for generated Swift models. Calling `close()` deterministically removes temporary files created for `as_path` secrets.
The SwiftPM release contains a checksummed XCFramework with the Rust resolver, so an application needs neither a Rust toolchain nor a separately installed SecretSpec library.
## Vault and OpenBao authentication
[Section titled “Vault and OpenBao authentication”](#vault-and-openbao-authentication)
[Vault](/providers/vault/) and [OpenBao](/providers/openbao/) deployments do not always use the default `approle` and `jwt` mount names. Their provider URIs can now choose a mount relative to `/v1/auth`:
```text
vault://vault.example.com:8200/secret?auth=approle&auth_mount=platform-approle
openbao://bao.example.com:8200/secret?auth=jwt&auth_mount=ci-jwt&role=deploy
```
AppRole authentication can omit `secret_id` when the server role is configured with `bind_secret_id=false`. JWT authentication can likewise omit its role when the selected mount has a server-configured `default_role`. Explicit URI, environment, or provider-credential inputs continue to take precedence.
## Upgrading
[Section titled “Upgrading”](#upgrading)
```bash
$ cargo install secretspec
```
0.18 also makes two local workflows less dependent on machine-specific setup:
* custom [dotenv](/providers/dotenv/) paths accept a leading `~`, resolved to the current user’s home directory;
* Linux [keyring](/providers/keyring/) builds use keyring 4’s Rust-native Secret Service transport, so SecretSpec binaries no longer require system `libdbus`.
Existing manifests and providers continue to work unchanged. The new commands, providers, and SDK are opt-in.
See the [full changelog](https://github.com/cachix/secretspec/blob/main/CHANGELOG.md) for every change and fix in this release.
Questions or feedback? Join us on [Discord](https://discord.gg/naMgvexb6q).
# SecretSpec 0.19: Moving and importing secrets between providers
> Move secrets safely between storage layouts, keep config and ephemeral values in the same declaration model, and make remote providers faster.
Secret storage changes as a project grows. Values move from local files to password managers, from one naming convention to another, and sometimes between providers with completely different data models. The application still expects the same `API_KEY` or `DATABASE_URL` at the end.
[SecretSpec 0.19](https://github.com/cachix/secretspec/releases/tag/v0.19.0 "SecretSpec 0.19 release") treats those changes as a normal workflow instead of a one-off migration script.
This release includes:
* **[Provider-specific storage layouts](#provider-specific-storage-layouts)**: give each provider its own address, transform stored values, import existing files, and preview exact write references.
* **[Config belongs in secrets](#config-belongs-in-secrets)**: resolve profile-specific config alongside stored secrets, generate ephemeral values, and securely prompt during `secretspec run`.
* **[Passbolt provider](#passbolt-provider)**: read and write secrets in a self-hosted Passbolt server, with credentials supplied by another provider when needed.
* **[Faster remote-provider workflows](#faster-remote-provider-workflows)**: attach a cache directly to an authoritative provider and batch 1Password field reads.
* **[Smaller improvements](#smaller-improvements)**: create standalone profiles and install complete pkg-config metadata for native SDK consumers.
## Provider-specific storage layouts
[Section titled “Provider-specific storage layouts”](#provider-specific-storage-layouts)
`API_KEY` is the name used by your application. In 1Password, the same value might be the `token` field of an item named `old-api-item`.
A [`ref`](/concepts/references/) gives SecretSpec this store address. `item` names the entry. Coordinates such as `field`, `section`, and `vault` locate a value inside structured stores. The `providers` list still decides which stores to try.
Before 0.19, every provider in a secret’s route received the same `ref`, even though stores such as 1Password and dotenv organize secrets differently. Now each provider alias can template its usual layout, while `refs.` handles exceptions:
secretspec.toml
```toml
[providers]
legacy = "onepassword://Legacy"
production = {
uri = "onepassword://Production",
ref = { item = "{project}-{profile}", field = "{key}" }
}
local = { uri = "dotenv://.env", ref = { item = "{key}" } }
[profiles.production]
API_KEY = {
description = "API key",
providers = ["production", "local"],
refs = { legacy = { item = "old-api-item", field = "token" } }
}
```
`production` reads the `API_KEY` field from a `-production` 1Password item. The `local` fallback reads the dotenv key `API_KEY`. If `legacy` is selected explicitly, `refs.legacy` reads the `token` field from `old-api-item`.
For each provider, `refs.` takes precedence over the alias’s `ref` template, which takes precedence over the provider convention. Templates accept `{project}`, `{profile}`, and `{key}` in every address field. Existing route-wide `ref` declarations remain supported.
Because scoped references also apply to imports, that exception can describe a migration source without joining the normal fallback route:
```bash
secretspec import legacy --profile production --delete-source
```
This reads from `refs.legacy` and writes through the `production` template. It also works between distinct entries in one physical store. SecretSpec rejects the import if both addresses resolve to the same entry.
### Transform stored values
[Section titled “Transform stored values”](#transform-stored-values)
Two new secret fields transform a stored value before it reaches the application.
`extract` selects a value from JSON with an [RFC 6901 JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901):
secretspec.toml
```toml
[providers]
runtime = "file:///run/secrets"
[profiles.production]
DATABASE_PASSWORD = {
description = "Database password",
providers = ["runtime"],
ref = { item = "application.json" },
extract = { format = "json", pointer = "/database/password" }
}
```
JSON strings become their unquoted contents. Numbers, booleans, objects, and arrays keep their JSON representation. Extracted declarations are read-only. `set`, `delete`, prompting, generation, and import cannot overwrite the source document.
`encoding` defines the textual representation in provider storage:
secretspec.toml
```toml
[profiles.production]
TEXT_CONFIG = { description = "Encoded configuration", encoding = "base64" }
CLIENT_KEYSTORE = {
description = "Binary client keystore",
providers = ["runtime"],
ref = { item = "client.p12.b64" },
encoding = "base64",
as_path = true
}
```
Supported encodings are standard Base64, URL-safe Base64, and hexadecimal. Writes encode the logical value. Reads decode the stored value. Decoded UTF-8 can be returned directly. Set `as_path = true` to materialize arbitrary bytes in a file.
Transforms run in this order:
```text
provider or cache → encoding decode → JSON extraction → as_path
```
This allows, for example, one declaration to decode a Base64-encoded JSON document and select one field from it.
### Import without reshaping the source
[Section titled “Import without reshaping the source”](#import-without-reshaping-the-source)
**[File](/providers/file/)** stores one plaintext UTF-8 file per secret beneath a required root. Convention paths use `{project}/{profile}/{key}`. `ref.item` selects an existing relative path, including a file mounted at runtime.
Writes use atomic replacement and create private Unix files and directories. The provider rejects traversal and nested symlinks. It does not encrypt its contents.
The file provider is also a migration adapter for directories that already contain one file per secret. A provider `ref` template maps the source layout, while the destination alias independently maps the same declarations into its native store:
secretspec.toml
```toml
[providers]
legacy_files = {
uri = "file:./old-secrets",
ref = { item = "{profile}/{key}" }
}
production = {
uri = "onepassword://Production",
ref = { item = "{project}-{profile}", field = "{key}" }
}
[profiles.production.defaults]
providers = ["production"]
[profiles.production]
API_KEY = { description = "Production API key" }
```
```bash
secretspec import legacy_files --profile production --delete-source
```
For `API_KEY`, the source is `old-secrets/production/API_KEY`. The destination is the `API_KEY` field in the `-production` 1Password item. The source files do not need to follow the SecretSpec convention. With `--delete-source`, 0.19 preflights every mapped source and destination, verifies every copied value, and only then removes the plaintext source files.
Preflight, write, or verification failures leave every source untouched. A destination with a different existing value keeps its corresponding source.
### See the reference before writing
[Section titled “See the reference before writing”](#see-the-reference-before-writing)
`secretspec set` and interactive `secretspec check` now print the resolved write reference before reading a value:
```console
$ secretspec set API_KEY --profile production --provider sops://secrets.enc.yaml
Writing secret 'API_KEY' to sops://secrets.enc.yaml?format=yaml (profile: production)
target: /work/my-app/secrets.enc.yaml ["my-app"]["production"]["API_KEY"]
Enter value for API_KEY (profile: production): ********
```
SOPS reports the canonical encrypted file and exact `sops set` selector. Other providers report their native item or path. A missing profile or unexpected template is visible before SecretSpec receives the new value.
## Config belongs in secrets
[Section titled “Config belongs in secrets”](#config-belongs-in-secrets)
**[Null](/providers/null/)** always reports a missing value and stores nothing. This lets manifest defaults provide non-sensitive values without adding a storage backend. One resolution can now return profile-specific configuration and provider-backed secrets together. This follows the separation described in [Secrets Don’t Belong in Config](/blog/secrets-dont-belong-in-config/).
secretspec.toml
```toml
[profiles.default]
APP_MODE = { description = "Application mode", default = "local", providers = ["null"] }
[profiles.staging]
APP_MODE = { default = "staging" }
[profiles.production]
APP_MODE = { default = "production" }
```
`APP_MODE` resolves to `local`, `staging`, or `production` based on the selected profile. Each override inherits the description and `null` route from `[profiles.default]`. Only the value is repeated.
The result is one declaration model for values the application needs, whether they come from a secret store or directly from the manifest. Config can travel through the same profile, scope, SDK, and `run` workflow without pretending it needs encrypted persistence.
### Ephemeral values
[Section titled “Ephemeral values”](#ephemeral-values)
The null provider can also generate a fresh value for each resolution. Use it for session tokens, test credentials, and other values that should exist only for one process invocation. Persistent credentials should continue to use a writable provider.
### Prompt for missing secrets during run
[Section titled “Prompt for missing secrets during run”](#prompt-for-missing-secrets-during-run)
Set `prompt = true` on a declaration to let `secretspec run` securely request its value when the configured providers do not have one:
secretspec.toml
```toml
[profiles.default]
DEPLOY_PASSWORD = {
description = "One-time deployment password",
prompt = true,
providers = ["null"]
}
```
```console
$ secretspec run -- ./deploy
? Enter value for DEPLOY_PASSWORD (profile: default):
```
A writable provider saves the answer, turning the prompt into first-use provisioning. The `null` provider keeps it ephemeral and injects it only into that invocation. The hidden prompt reads from the controlling terminal, so the child’s stdin remains available for pipes and redirects. If no controlling terminal exists, `run` fails before starting the child. Declarations without `prompt = true` retain the existing fail-on-missing behavior.
## Passbolt provider
[Section titled “Passbolt provider”](#passbolt-provider)
Passbolt is the third new provider in 0.19. SecretSpec now has 27 providers.
**[Passbolt](/providers/passbolt/)** reads and writes resources in a self-hosted Passbolt server through `go-passbolt-cli`. Convention values use the resource `secretspec/{project}/{profile}/{key}` and its `password` field. References can select existing resources by UUID or exact name and address the `password`, `username`, `uri`, or `description` field.
secretspec.toml
```toml
[providers]
bootstrap = "keyring://"
[providers.passbolt_team]
uri = "passbolt://?server=https://pass.example.com&folder=a9230ec4-5507-4870-b8b5-b3f500587e4c"
credentials = { private_key = "bootstrap", passphrase = "bootstrap" }
```
The OpenPGP private key and passphrase can come from another SecretSpec provider. Environment fallbacks and the Passbolt CLI configuration are also supported. Folder-scoped providers support declaration discovery with `init --from`.
## Faster remote-provider workflows
[Section titled “Faster remote-provider workflows”](#faster-remote-provider-workflows)
Remote secret reads pay for authentication, process startup, and network round-trips before the application can start. SecretSpec 0.19 reduces that work both across invocations and within one resolution.
### Cache one authoritative provider
[Section titled “Cache one authoritative provider”](#cache-one-authoritative-provider)
A single authoritative provider can now define `uri`, `credentials`, and `cache` on the same alias:
secretspec.toml
```toml
[providers]
local = "keyring://secretspec/cache/{project}/{profile}/{key}"
azure = {
uri = "akv://team-vault",
credentials = { client_secret = "keyring" },
cache = { provider = "local", max_age = "8h" }
}
[profiles.development.defaults]
providers = ["azure"]
```
The cached `fallback` form introduced in 0.17 remains available when several authoritative providers can answer.
Cache entries now include their absolute expiration time and originating `max_age`. SecretSpec removes an expired entry whenever it encounters one, and changing `max_age` invalidates entries written under the previous policy.
Fallback resolution also reuses provider instances and handles independent primary misses concurrently. Azure Key Vault reuses its client and serializes initial challenge-based authentication, avoiding repeated Azure CLI processes within one resolution.
### Batch 1Password field reads
[Section titled “Batch 1Password field reads”](#batch-1password-field-reads)
1Password field references now resolve together through one `op inject` call, instead of starting `op read` separately for every field. This reduces CLI startup and repeated unlock overhead when one profile loads several fields. If the batch contains a missing reference, SecretSpec falls back to bounded concurrent reads so it can preserve per-secret missing-value behavior without serializing the whole profile.
In the cold-cache benchmark from [the implementation PR](https://github.com/cachix/secretspec/pull/317), a representative profile with 25 field references resolved in 11.890 seconds, down from 96.294 seconds. The batch used 3 `op` processes instead of 27, making that run 8.10 times faster.
## Smaller improvements
[Section titled “Smaller improvements”](#smaller-improvements)
### Standalone profiles
[Section titled “Standalone profiles”](#standalone-profiles)
Profiles inherit `[profiles.default]` unless their defaults set `inherit = false`:
secretspec.toml
```toml
[profiles.default]
DEV_DATABASE_URL = { description = "Developer database" }
LOCAL_DEBUG_TOKEN = { description = "Local debugging token", required = false }
[profiles.production.defaults]
inherit = false
providers = ["vault://vault.example.com:8200/secret"]
[profiles.production]
DATABASE_URL = { description = "Production database" }
API_KEY = { description = "Production API key" }
```
`production` contains only its own declarations and fields. Other profiles in the same manifest can continue to inherit the default profile.
### pkg-config metadata for secretspec-ffi
[Section titled “pkg-config metadata for secretspec-ffi”](#pkg-config-metadata-for-secretspec-ffi)
> This section describes the 0.19 names. In SecretSpec 0.20+, the component is named `libsecretspec`, installed with `cargo cinstall -p libsecretspec`, and provides `libsecretspec.pc`.
`cargo cinstall -p secretspec-ffi` now installs the library, C header, and a `secretspec_ffi.pc` file containing the complete link metadata. Go builds can use the `pkgconfig` tag, Ruby native extensions accept `--enable-pkg-config`, and Haskell builds use the `use-pkg-config` Cabal flag. The same metadata supports installed static or shared libraries.
Haskell now declares its required macOS system frameworks. The Rust SDK’s `ProviderAlias` type also exposes `leaf`, `credentials`, and `credentials_mut` helpers for configuration tooling.
## Upgrading
[Section titled “Upgrading”](#upgrading)
```bash
cargo install secretspec
```
Existing route-wide `ref` declarations, inheriting profiles, and cached fallback aliases remain compatible. All new configuration fields and providers are opt-in.
0.19 also:
* fixes [concurrent keyring initialization](https://github.com/cachix/secretspec/issues/268).
* preserves [non-UTF-8 environment values in `run` on Unix](https://github.com/cachix/secretspec/issues/140).
* renders [SOPS path templates in one pass](https://github.com/cachix/secretspec/pull/271) and [validates deserialized path templates](https://github.com/cachix/secretspec/commit/bd448ad821d251f1d38a4235a1db868372bb2bd3).
* preserves [complete multi-segment LastPass templates in route comparisons](https://github.com/cachix/secretspec/issues/272).
* [refreshes fallback providers when a Rust `Secrets` instance is reused](https://github.com/cachix/secretspec/issues/283).
* adds [`Secrets::resolve_named`](https://github.com/cachix/secretspec/pull/315) for resolving one secret without unrelated missing requirements.
* rejects [credentials embedded in provider URIs](https://github.com/cachix/secretspec/pull/315). Use alias credentials or provider environment variables instead.
See the [full changelog](https://github.com/cachix/secretspec/blob/main/CHANGELOG.md) for every change and fix in this release.
## Future work
[Section titled “Future work”](#future-work)
These items are not part of 0.19. They are open work for future releases:
* **Native Windows ARM64 CLI archive (target: 0.19.1)**: add `secretspec-aarch64-pc-windows-msvc.zip` and its checksum to GitHub Releases so the CLI can run natively on Windows ARM64. The static installer will continue to select the x64 build on Windows ARM devices until it supports the native archive, and standalone updates depend on [axoupdater supporting Windows ARM64](https://github.com/axodotdev/axoupdater/pull/357).
* **WinGet packaging**: publish the initial package tracked in [microsoft/winget-pkgs#413776](https://github.com/microsoft/winget-pkgs/pull/413776), then automate stable updates through [SecretSpec #297](https://github.com/cachix/secretspec/pull/297).
* **[Notification and approval integrations](https://github.com/cachix/secretspec/issues/300)**: send new secret access requests to services such as email, Slack, or WhatsApp for approval.
* **[JVM SDK](https://github.com/cachix/secretspec/issues/310)**: expose the shared SecretSpec resolver to Java, Kotlin, and other JVM languages.
* **[Dart SDK](https://github.com/cachix/secretspec/issues/240)**: bring the shared resolver to Dart and Flutter applications.
Every team has a secrets story. Come tell us yours on [Discord](https://discord.gg/naMgvexb6q).
# SecretSpec 0.20: Git, Docker, inline specs, and five new providers
> Let Git and Docker retrieve credentials from any provider, declare secrets in application code, use the JVM SDK, and deploy on Alpine.
[SecretSpec 0.20](https://github.com/cachix/secretspec/releases/tag/v0.20.0 "SecretSpec 0.20 release") brings SecretSpec-managed credentials to Git and Docker, lets applications declare secrets directly in code, adds five providers, and expands support for JVM and Alpine applications.
This release includes:
* **[Git and Docker credential helpers](#git-and-docker-credential-helpers)**: let ordinary Git and Docker commands retrieve credentials from any SecretSpec provider without copying them into another credential store.
* **[Declarations in application code](#declarations-in-application-code)**: build secret specifications in Rust and prompt for missing values from typed loaders.
* **[libsecretspec and SDKs](#libsecretspec-and-sdks)**: pass inline specifications from eight SDKs, attach caller context, and adopt the new name for `secretspec-ffi`.
* **[Five new providers](#five-new-providers)**: use [Azure App Configuration](/providers/aac/), [Kubernetes](/providers/kubernetes/), [EJSON](/providers/ejson/), [Fly.io](/providers/fly/), and [Cloudflare Secrets Store](/providers/cloudflare/).
* **[JVM and Alpine support](#jvm-and-alpine-support)**: resolve secrets from Java and Kotlin, install static musl CLI builds, and run the Node.js SDK in Alpine images.
* **[Other changes](#other-changes)**: forward container signals, report missing generated secrets correctly, emit shell completions, and make dotenv values round-trip.
## Git and Docker credential helpers
[Section titled “Git and Docker credential helpers”](#git-and-docker-credential-helpers)
Git and Docker can now read credentials directly from any SecretSpec provider. Configure each helper once, then keep using the usual Git and Docker commands.
### Git credentials
[Section titled “Git credentials”](#git-credentials)
The [Git integration](/integrations/git/) registers a helper for a host, then stores its token through your normal SecretSpec provider:
```bash
$ secretspec git configure \
--url https://github.com \
--username YOUR_USERNAME
$ secretspec git login https://github.com
```
After that, normal commands invoke `git-credential-secretspec` automatically:
```bash
$ git clone https://github.com/OWNER/PRIVATE_REPOSITORY.git
$ git push
```
The helper works before a repository has been cloned and can scope credentials by host or URL path. It also supports SMTP credentials for `git send-email`, so the password does not need to live in `sendemail.smtpPass`.
### Docker credentials
[Section titled “Docker credentials”](#docker-credentials)
The [Docker integration](/integrations/docker/) uses one helper per registry. Configure the registry and store its token separately:
```bash
$ secretspec docker configure \
--registry ghcr.io \
--username YOUR_USERNAME
$ secretspec docker login ghcr.io
```
`docker pull`, `docker push`, `docker build`, and Docker Compose can now retrieve the value through `docker-credential-secretspec`:
```bash
$ docker pull ghcr.io/OWNER/IMAGE:TAG
```
Docker’s `config.json` contains only the helper configuration, not the credential value. Separate `DOCKER_CONFIG` directories can use different credentials for the same registry.
The helpers only read credentials. Use `secretspec git login` / `logout` and `secretspec docker login` / `logout` to change stored values. Use `configure` and `unconfigure` to add or remove the helpers themselves.
## Declarations in application code
[Section titled “Declarations in application code”](#declarations-in-application-code)
`secretspec.toml` remains the portable contract shared by the CLI and every SDK. Applications can now define that contract directly when keeping a separate manifest is inconvenient.
### Rust-first specifications
[Section titled “Rust-first specifications”](#rust-first-specifications)
Rust applications can use the new public `Spec`, `Profile`, and `Secret` types to build a specification in code:
```rust
use secretspec::{Profile, Secret, Secrets, Spec};
let spec = Spec::builder("checkout")
.provider("env", "env://")
.secret(
"DATABASE_URL",
Secret::required("PostgreSQL connection URL").providers(["env"]),
)
.profile(
"production",
Profile::new().secret(
"SENTRY_DSN",
Secret::required("Production Sentry endpoint"),
),
)
.build()?;
let mut secrets = Secrets::from_spec(spec)?;
secrets.set_profile("production");
let resolved = secrets.resolve()?;
```
Rust-built specs use the same validation as `secretspec.toml`. `Spec::schema_json` generates the same JSON Schema as `secretspec schema`, and `SpecBuilder` can update a file-backed spec without losing comments or formatting.
See [Describing secrets in Rust](/sdk/rust/#describing-secrets-in-rust-020) for the complete builder and format-preserving editing API.
Typed Rust loaders generated by `declare_secrets!` can now call [`prompt_missing()`](/sdk/rust/#interactive-prompting-020) to ask for and store missing required values. Prompting remains opt-in.
## libsecretspec and SDKs
[Section titled “libsecretspec and SDKs”](#libsecretspec-and-sdks)
SecretSpec 0.20 gives the SDKs the same new capabilities across languages. It also renames `secretspec-ffi` to [`libsecretspec`](/sdk/overview/). Packaged SDKs handle the rename automatically; update your build only if you link or load `libsecretspec` directly.
### Inline specifications
[Section titled “Inline specifications”](#inline-specifications)
The [SDKs](/sdk/overview/) for Go, Python, Node.js, Ruby, Haskell, PHP, C#, and Swift can now accept an inline specification. This is useful when the application already owns its configuration or cannot rely on a manifest on disk. For example, in Go:
```go
spec := map[string]any{
"project": map[string]any{"name": "checkout"},
"profiles": map[string]any{"default": map[string]any{
"secrets": map[string]any{
"API_TOKEN": map[string]any{"description": "API token"},
},
}},
}
resolved, err := secretspec.New().
WithInlineSpec(spec, "/logical/project").
Load()
```
The base directory tells SecretSpec where to resolve relative provider paths and inherited manifests. Invalid fields and unsupported spec versions fail with an error.
### Caller context
[Section titled “Caller context”](#caller-context)
Git and Docker set caller context automatically, recording which tool and operation requested a secret. CLI and SDK callers can provide the same context:
```bash
$ secretspec get GITHUB_TOKEN \
--caller git \
--caller-version 2.51.0 \
--caller-operation credential_get \
--caller-resource github.com \
--reason "push release tag"
```
[Audit records](/concepts/audit/) include the caller name, version, operation, and resource. This is separate from [`require_reason`](/reference/configuration/#requiring-a-reason-for-secret-access), so policies can record both which tool accessed a secret and why.
### JVM and Alpine support
[Section titled “JVM and Alpine support”](#jvm-and-alpine-support)
The new [JVM SDK](/sdk/jvm/) lets Java, Kotlin, and other JVM applications load SecretSpec secrets directly. Add it with Gradle:
```kotlin
dependencies {
implementation("org.cachix:secretspec-jvm:0.20.0")
}
```
Then load secrets with the same builder pattern as the other SDKs:
```java
import org.cachix.secretspec.SecretSpec;
try (var resolved = SecretSpec.builder()
.withProvider("keyring://")
.withProfile("production")
.withReason("boot web app")
.load()) {
System.out.println(resolved.secret("DATABASE_URL").get());
resolved.setAsSystemProperties();
}
```
The package supports JDK 11 or newer on Linux, macOS, and Windows, including x64 and Arm64 systems. It includes everything needed to resolve secrets, so the application does not need a separate SecretSpec CLI or Rust toolchain.
The standalone installer and `secretspec-update` now work on Alpine Linux. The Node.js SDK also works directly in images such as `node:alpine`, without a glibc compatibility layer.
## Five new providers
[Section titled “Five new providers”](#five-new-providers)
SecretSpec 0.20 adds five providers, bringing the total to 33.
### Azure App Configuration
[Section titled “Azure App Configuration”](#azure-app-configuration)
Use **[Azure App Configuration](/providers/aac/)** to read and manage ordinary key-values or follow references to Azure Key Vault secrets.
```bash
$ az login
$ secretspec set DATABASE_URL --provider aac://payments-production
$ secretspec run --provider aac://payments-production -- ./payments
```
It works with service principals, Azure CLI sessions, managed identity, workload identity, and connection strings. Provider URLs can select labels, prefixes, and tags.
### Kubernetes
[Section titled “Kubernetes”](#kubernetes)
**[Kubernetes](/providers/kubernetes/)** stores values in a ConfigMap or Secret using the current kubeconfig context:
```bash
$ secretspec set DATABASE_URL \
--provider k8s+secret://app-credentials@production
$ secretspec run \
--provider k8s+secret://app-credentials@production \
-- ./application
```
You can use convention keys or point declarations at existing `.data` entries. The provider supports reads, writes, deletion, and declaration discovery.
### EJSON
[Section titled “EJSON”](#ejson)
**[Shopify’s EJSON](https://github.com/Shopify/ejson)** lets teams keep encrypted secrets alongside application source. Secret values are encrypted with a public key and decrypted with the matching private key, while the JSON structure stays visible.
SecretSpec’s [EJSON provider](/providers/ejson/) reads string values from those files. A `ref.item` selects any RFC 6901 JSON Pointer, while convention addresses use `/{project}/{profile}/{key}`.
Supply the EJSON private key as a SecretSpec [provider credential](/reference/provider-credentials/). It can come from Google Cloud Secret Manager, the system keyring, or any other readable provider. The EJSON provider is read-only.
### Fly.io
[Section titled “Fly.io”](#flyio)
Use **[Fly.io](/providers/fly/)** to publish and delete application secrets with `flyctl`. Secret values stay out of process arguments, and app-scoped deploy tokens can come from provider credentials. Fly.io does not return plaintext, so keep the original value in a readable provider and use `fly` as the deployment destination.
### Cloudflare Secrets Store
[Section titled “Cloudflare Secrets Store”](#cloudflare-secrets-store)
Use **[Cloudflare Secrets Store](/providers/cloudflare/)** to publish and delete account-level secrets through Cloudflare’s API. Authenticate with a scoped API token from another provider or an existing Wrangler session. Cloudflare does not return plaintext, so keep the original value in a readable provider and use `cloudflare` as the deployment destination.
## Other changes
[Section titled “Other changes”](#other-changes)
Shell completions now come directly from the CLI definition:
```bash
$ source <(secretspec completions bash)
```
Bash, Elvish, Fish, Nushell, PowerShell, and Zsh now complete commands, profiles, scopes, secret names, providers, files, and executables. Completion does not read secret values. See [`secretspec completions`](/reference/cli/#completions-020) for persistent installation instructions ([#330](https://github.com/cachix/secretspec/pull/330)).
0.20 also includes these CLI and automation changes:
* On Unix, `secretspec run` forwards `SIGTERM`, `SIGINT`, and `SIGHUP` to its child, so containers can shut down gracefully ([#391](https://github.com/cachix/secretspec/pull/391)).
* `check --json`, `check --explain`, and SDK reports now mark an unprovisioned required [`generate` declaration](/concepts/generation/) as `missing_required` instead of resolved. Run `secretspec check` or `secretspec run` once to generate and store it ([#394](https://github.com/cachix/secretspec/pull/394)).
* Human-readable `secretspec check` output moves to stdout, matching its JSON and explain modes. Diagnostics remain on stderr ([#372](https://github.com/cachix/secretspec/issues/372)).
* Dotenv files, age-encrypted dotenv blobs, and `export --format dotenv` now use dotenv-ng. Dollar signs and bcrypt-style values now round-trip correctly ([#73](https://github.com/cachix/secretspec/issues/73)).
* [`extract`](/reference/configuration/#structured-extraction-019) supports INI documents, selecting unsectioned keys with `/key` and named-section keys with `/section/key` ([#386](https://github.com/cachix/secretspec/pull/386)).
* The [age provider](/providers/age/) can now delete secrets, enabling `secretspec delete`, `import --delete-source`, and age-backed provider caches ([#328](https://github.com/cachix/secretspec/pull/328)).
* Closing a stdout pipe is quiet on Unix, so commands such as `secretspec export | head` behave like other Unix tools ([#377](https://github.com/cachix/secretspec/pull/377)).
Provider-specific fixes keep 1Password batches fast when optional items are missing ([#401](https://github.com/cachix/secretspec/pull/401)), prevent Bitwarden convention names from colliding across projects and profiles ([#390](https://github.com/cachix/secretspec/pull/390)), make Infisical Universal Auth sessions more reliable ([#402](https://github.com/cachix/secretspec/pull/402)), and let Node.js applications exit cleanly after AWS resolution ([#365](https://github.com/cachix/secretspec/pull/365)).
## Upgrading
[Section titled “Upgrading”](#upgrading)
```bash
$ cargo install secretspec
```
For most users, 0.20 is a drop-in upgrade. The new integrations, providers, and inline declarations are opt-in. Check these cases before upgrading:
* scripts that captured human-readable `secretspec check` output from stderr should capture stdout;
* Google Cloud Secret Manager writes now use the collision-safe `secretspec2--{project}--{profile}--{key}` convention. Reads fall back to the matching 0.19 name until a write moves the value, leaving the old secret in place for rollback;
* Bitwarden Password Manager convention items now use `secretspec/{project}/{profile}/{key}` titles. `init --from bw://` preserves references to legacy bare items so they can keep working or be migrated deliberately.
Rust applications using `secretspec-derive` can remove direct `serde` or `secrecy` dependencies if they were needed only for generated types. Code using the old raw configuration or code-generation APIs should move to `Spec`, `SpecBuilder`, `Profile`, and `Secret`.
See the [full changelog](https://github.com/cachix/secretspec/blob/main/CHANGELOG.md) for every change and fix in this release.
## What’s next
[Section titled “What’s next”](#whats-next)
### Resolver and provider IPC
[Section titled “Resolver and provider IPC”](#resolver-and-provider-ipc)
The resolver and provider IPC protocols are currently RFCs in [PR #362](https://github.com/cachix/secretspec/pull/362). The [resolver protocol](https://feat-ipc-v1-secretspec.domen.workers.dev/reference/resolver-protocol/) would let applications request individual secrets from `secretspec serve`. The [provider protocol](https://feat-ipc-v1-secretspec.domen.workers.dev/reference/provider-protocol/) would let external executables act as SecretSpec providers. Both are targeted for 0.21 and may change during review.
### Nix secret-provider plumbing
[Section titled “Nix secret-provider plumbing”](#nix-secret-provider-plumbing)
Work on the Nix integration identified that SecretSpec first needs the IPC layer above, so the concrete integration is blocked until 0.21 is released. In the meantime, the underlying refactoring has been split into a [generic secret-provider interface for Nix](https://github.com/NixOS/nix/pull/16339).
### devenv machines
[Section titled “devenv machines”](#devenv-machines)
Separately, [devenv’s experimental `machines` interface](https://github.com/cachix/devenv/pull/3073) aims to handle NixOS installation, NixOS and nix-darwin deployment, and home-manager activation in one workflow. Its SecretSpec integration can supply deployment credentials without putting their values in Nix evaluation or store paths. This work follows devenv’s own release schedule and is not part of SecretSpec 0.21.
Questions or feedback? Join us on [Discord](https://discord.gg/naMgvexb6q).
## Fencer for Open Source
[Section titled “Fencer for Open Source”](#fencer-for-open-source)
In partnership with SecretSpec, [Fencer has expanded its offering to open source](https://www.fencer.dev/blog/introducing-fencer-for-open-source). Public repositories now get free static analysis, dependency scanning, secret scanning, and GitHub configuration scanning, with no credit card and no expiry.
We recommend Fencer for code security scanning, and Fencer recommends SecretSpec for secrets management. Fencer finds credentials and other risks already in a repository, while SecretSpec helps keep the next secret in the right provider instead of source code or a dotenv file.
# We Are Forking dotenvy into dotenv-ng
> A modern Rust dotenv implementation with literal dollars, round-trip rendering, and structured parse errors.
We have released [`dotenv-ng`](https://github.com/cachix/dotenv-ng) 1.0, a modern Rust implementation for loading and rendering `.env` files. It began as a fork of [`dotenvy`](https://github.com/allan2/dotenvy) after its parser changed a secret while reading it.
That may sound contradictory. SecretSpec is still on a mission to [eliminate environment variables as a secrets interface](/blog/secrets-dont-belong-in-config/), and we have written about [where `.env` went wrong](/blog/where-env-went-wrong/). It should not be the final home of a secret.
But migrating away from `.env` starts with reading it correctly.
## Why fork dotenvy?
[Section titled “Why fork dotenvy?”](#why-fork-dotenvy)
The immediate failure was [SecretSpec issue #73](https://github.com/cachix/secretspec/issues/73). A dotenv file contained a value with bcrypt fragments:
```dotenv
TEST="foo:$2a$10$TWoviNHS27HJMw1PKe4tBeIMlms6tWdYS9hKoHANKCQhluDlEt/gu"
```
The file was intact. Reading it through the dotenv provider returned a different value because `dotenvy` treated the dollar-prefixed fragments as variable substitutions. The failure appeared later as an authentication error, not a parse error.
An upstream request to make substitution configurable had been [open since 2024](https://github.com/allan2/dotenvy/issues/113). A [pull request](https://github.com/allan2/dotenvy/pull/167) arrived in 2026 but targeted an unreleased API. A migration tool cannot require users to recognize and escape parser syntax inside their secrets.
## The maintenance gap
[Section titled “The maintenance gap”](#the-maintenance-gap)
The original Rust `dotenv` crate stopped releasing in 2020 and was eventually marked [unmaintained by RustSec](https://rustsec.org/advisories/RUSTSEC-2021-0141.html), which listed dotenvy as an alternative.
Dotenvy’s description still calls it “a well-maintained fork.” Its latest published version, [0.15.7, was released on March 22, 2023](https://github.com/allan2/dotenvy/releases/tag/v0.15.7). A [Rust forum discussion](https://users.rust-lang.org/t/recommended-crate-for-storing-keys-for-web-site-database/133305/11) noted the two-year release gap in 2025. By the time the bcrypt bug blocked SecretSpec, it was more than three years.
There is an uncomfortable irony in a maintained fork repeating its upstream’s release problem. Its maintainers do not owe us a release, but SecretSpec needed breaking fixes on a schedule we control.
## What does dotenv-ng improve upon?
[Section titled “What does dotenv-ng improve upon?”](#what-does-dotenv-ng-improve-upon)
We first considered a small patch. Auditing the parser uncovered more problems around JSON, Windows paths, Unicode names, precedence, and partial environment mutation.
`dotenv-ng` therefore starts from dotenvy 0.15.7 but deliberately breaks compatibility where correctness requires it. Version 1.0 adds:
* a source-aware parser with structured errors;
* literal dollar signs by default, with substitution available only when a caller explicitly enables it;
* a broader key grammar that supports dashes, leading digits, leading dots, and Unicode;
* a renderer that adds only the quoting and escaping needed to parse a value back unchanged;
* validation before process-environment mutation; and
* an explicit `unsafe` boundary around that mutation.
Property tests exercise arbitrary Unicode and syntax-heavy values, check that quoting is used only when necessary, and round-trip complete documents. The parser and renderer, the core of the rewrite, both have 100% line coverage.
The complete compatibility and API changes are recorded in the [`dotenv-ng` 1.0 changelog](https://github.com/cachix/dotenv-ng/blob/v1.0.0/CHANGELOG.md).
## Try dotenv-ng 1.0
[Section titled “Try dotenv-ng 1.0”](#try-dotenv-ng-10)
The package is available on [crates.io](https://crates.io/crates/dotenv-ng). Applications can keep the familiar `dotenv` crate name with a dependency alias:
```toml
[dependencies]
dotenv = { package = "dotenv-ng", version = "1" }
```
Starting in SecretSpec 0.20, dotenv-ng powers dotenv parsing and rendering throughout SecretSpec.
# Where .env Went Wrong
> A local convenience became a secrets interface, an environment model, and a deployment format. It was never designed to be any of them.
`.env` is one of software’s most successful accidents.
It starts as a shortcut for three `export` commands. Then it becomes the project’s configuration schema, secret store, environment model, onboarding guide, CI interface, and deployment format.
Environment variables do one job well: deliver strings to a process. `.env` turned that delivery mechanism into a source of truth.
A convenience became architecture. That is where `.env` went wrong.
## Environment variables only deliver values
[Section titled “Environment variables only deliver values”](#environment-variables-only-deliver-values)
Environment variables solve a small problem: getting values into a running process. The application can read `DATABASE_URL` without knowing whether a developer, CI system, or secrets manager supplied it.
A `.env` file makes those values easy to save and reload. That is useful. But teams also use the file to describe what the application needs. `KEY=value` cannot say whether a value is required, secret, safe to commit, available only in production, or restricted to one service.
Those requirements outlive any process and any developer laptop. They belong in a durable project declaration. `.env` stores values for delivery; it cannot define the application’s secret model.
How does SecretSpec solve this?
SecretSpec separates the committed [declaration](/concepts/declarative/) from value [storage](/concepts/providers/) and delivery. The CLI and [SDKs](/sdk/overview/) resolve the same declaration regardless of where values live or how applications receive them.
## A string is not a schema
[Section titled “A string is not a schema”](#a-string-is-not-a-schema)
Consider a typical example file:
.env.example
```dotenv
DATABASE_URL=
REDIS_URL=redis://localhost:6379
STRIPE_API_KEY=
DEBUG=false
```
The file raises more questions than it answers. Does an empty value mean required or optional? Is `REDIS_URL` a development default? Is `STRIPE_API_KEY` production-only? Is `DEBUG` a boolean?
Dotenv cannot encode those answers. Node.js documents that [every value becomes a string](https://nodejs.org/api/environment_variables.html#variable-values). A [dotenv issue about booleans](https://github.com/motdotla/dotenv/issues/51), opened in 2015, still collects reactions from developers surprised that `"false"` is truthy.
Teams put the missing information elsewhere: validation code, a README, `.env.example`, or a teammate’s memory. These sources drift.
The file also makes `DEBUG` and `STRIPE_API_KEY` look equivalent. One is an ordinary setting that belongs in Git. The other grants authority and needs access control and rotation. Mixing them makes the whole file sensitive.
Without an explicit declaration, missing values fail late: the application discovers them only when code tries to use them.
How does SecretSpec solve this?
The [SecretSpec declaration](/reference/configuration/) records names, descriptions, required values, and safe defaults. `secretspec check` and `secretspec run` validate those requirements before the application starts, while ordinary settings such as `DEBUG` remain in application configuration.
## Then the file starts to multiply
[Section titled “Then the file starts to multiply”](#then-the-file-starts-to-multiply)
A new requirement usually creates another file:
```text
.env
.env.local
.env.development
.env.development.local
.env.test
.env.production
```
The filenames become an environment model. Suffixes define scope, load order defines inheritance, and copying a file becomes deployment.
This reverses the [Twelve-Factor App’s guidance](https://12factor.net/config). Its point was that environment variables should be independent controls because named environments become brittle as deployments multiply. `.env.production` recreates that grouping in a filename.
Now every new value must be added to `.env.example`, documented in a README, validated in code, and copied into the right real files. Miss one and the environments drift.
How does SecretSpec solve this?
[Profiles](/concepts/profiles/) express real requirement differences as sparse overlays on `profiles.default`. Each deployment selects its values through providers instead of maintaining a complete, copied secret file.
## There is no `.env` spec
[Section titled “There is no .env spec”](#there-is-no-env-spec)
`.env` looks standardized, but every parser defines its own format. [Node.js documents the lack of a formal specification](https://nodejs.org/api/environment_variables.html#env-files), as does [python-dotenv](https://github.com/theskumar/python-dotenv#file-format). Each loader makes its own choices.
python-dotenv expands `${NAME}` but not `$NAME`. Node dotenv delegates [variable expansion](https://github.com/motdotla/dotenv#variable-expansion) to another tool. Docker Compose supports its own [shell-style operators](https://github.com/compose-spec/compose-spec/blob/main/spec.md#interpolation). Vite even supports references in reverse order, then [warns](https://main.vite.dev/guide/env-and-mode#expanding-variables-in-reverse-order) that the same expression will not work in a shell or Docker Compose.
Comments and quotes differ too. Node dotenv changed the meaning of `#` in unquoted values in version 15 as a [breaking change](https://github.com/motdotla/dotenv#comments). One devenv user found that [quotes became part of an exported key](https://github.com/cachix/devenv/issues/1333).
How does SecretSpec solve this?
SecretSpec defines one TOML declaration and one resolution model shared by its CLI and [SDKs](/sdk/overview/). Dotenv parsing is confined to the [compatibility provider](/providers/dotenv/), so changing loaders or storage backends does not change the application’s declaration.
## Which value wins?
[Section titled “Which value wins?”](#which-value-wins)
Parsers also disagree about precedence. [Node dotenv](https://github.com/motdotla/dotenv#path) normally lets the first file win. [Docker Compose](https://github.com/compose-spec/compose-spec/blob/main/spec.md#env_file) lets the last `env_file` win, then lets the `environment` section override that. [Vite](https://main.vite.dev/guide/env-and-mode#env-loading-priorities) gives an existing process variable priority over its files.
Docker Compose gives two similar names different behavior. `env_file:` supplies variables to a container but does not use them to interpolate `compose.yaml`. `docker compose --env-file` does affect interpolation. In [an issue closed as working as designed](https://github.com/docker/compose/issues/9443), a maintainer described the option’s name as unfortunately chosen.
How does SecretSpec solve this?
SecretSpec applies one deterministic [provider resolution order](/concepts/providers/fallback/#provider-selection-order). Per-secret routes and fallbacks are explicit in the declaration, and the same resolver applies them across the CLI and SDKs.
## Who loaded `.env` first?
[Section titled “Who loaded .env first?”](#who-loaded-env-first)
Precedence also depends on timing. Node dotenv’s [ES module guidance](https://github.com/motdotla/dotenv/blob/94f6542d5c8b1ab211cab0dcd8f7aa907dd39124/README.md#L406-L435) needs special handling when imported modules read the environment during initialization. Vite warns that Bun’s automatic `.env` loading can interfere with [Vite’s own loading order](https://main.vite.dev/guide/env-and-mode#env-files). `VITE_*` values are replaced at build time and become part of the [client bundle](https://main.vite.dev/guide/env-and-mode#env-variables).
The same line can become a runtime secret, a build-time constant, or a public browser value. The loader decides based on timing and context.
At that point `.env` behaves like a small program, with control flow spread across filenames, flags, working directories, parent processes, and library versions.
How does SecretSpec solve this?
[`secretspec run`](/reference/cli/#run) resolves and validates secrets before launching the child, so its complete environment exists from process startup. Applications using an SDK load the declaration explicitly instead of depending on a module-import side effect.
## An ignored file is still a file
[Section titled “An ignored file is still a file”](#an-ignored-file-is-still-a-file)
The dotenv project says [not to commit `.env`](https://github.com/motdotla/dotenv#should-i-commit-my-env-file). `.gitignore` prevents one accident. It does not add encryption, access control, auditing, or revocation.
The file can still end up in editor backups, chat messages, archives, support bundles, container build contexts, and old laptops. A devenv integration was [reported to copy `.env` contents into the Nix store](https://github.com/cachix/devenv/issues/1694), where paths are not confidential. When a developer leaves, there is no file access to revoke. Each credential they received is a separate copy.
Even a secret stored in 1Password, Vault, a cloud secret manager, or a system keyring must be copied into plaintext before a dotenv-based application can use it. The local copy has fewer controls than the original.
Environment-variable delivery has limits too. Docker mounts managed secrets as files because environment variables can [leak between containers](https://docs.docker.com/engine/swarm/secrets/#build-support-for-docker-secrets-into-your-images). A process also gets one global map, so a frontend build, worker, migration, and web service often receive the same secrets even when each needs only a few. Dotenv has no way to express that scope.
How does SecretSpec solve this?
The committed declaration contains no secret values. Providers supply storage, encryption, identity, and access control, while the [metadata-only audit log](/concepts/audit/) records local access. [Scopes (0.17+)](/concepts/scopes/) let each service or command resolve only its declared subset.
## Let `.env` become small again
[Section titled “Let .env become small again”](#let-env-become-small-again)
The useful part of `.env` is the short path from “this application needs a value” to “the application can run.”
Keep it as an adapter for tools that expect `KEY=value`, or use it for ordinary local settings. Do not make it define the project’s requirements, store durable copies of secrets, encode environments in filenames, or decide which services receive which values.
A durable design separates three jobs:
* a committed declaration says which secrets the application needs;
* protected storage controls who can read their values;
* explicit delivery gives each process only the values it needs.
Each piece can then change independently. A team can change storage without rewriting the application, validate requirements before startup, and limit each component to its own secrets.
## How SecretSpec applies this
[Section titled “How SecretSpec applies this”](#how-secretspec-applies-this)
SecretSpec puts the declaration in a file that is safe to commit:
secretspec.toml
```toml
[project]
name = "payments"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "Postgres connection string" }
REDIS_URL = { description = "Redis connection string", required = false }
STRIPE_API_KEY = { description = "Stripe API key" }
[profiles.development]
REDIS_URL = { default = "redis://localhost:6379" }
```
This file records requirements, defaults, and descriptions without containing secret values. [Providers](/concepts/providers/) choose where values live, and [profiles](/concepts/profiles/) describe real differences in requirements.
Existing programs can adopt SecretSpec without code changes:
```bash
$ secretspec run -- ./server
```
This command injects resolved secrets into the child process environment. It is useful during migration, while the preferred integration is a [SecretSpec SDK](/sdk/overview/).
With an SDK, the application resolves its declaration directly. This removes the environment-variable handoff used by `secretspec run`. Values stored in a keyring, password manager, or Vault never enter the global process environment. Applications that require a file can receive a [temporary file](/reference/configuration/#as_path-option) instead. [Scopes (0.17+)](/concepts/scopes/) let each component resolve only the secrets it declares.
Migration can be gradual. SecretSpec initializes a declaration from an existing file:
```bash
$ secretspec init --from dotenv:.env
```
This copies names without copying values. The current file can remain a provider during the transition:
```bash
$ secretspec check --provider dotenv:.env
$ secretspec run --provider dotenv:.env -- ./server
```
Values can then move to a system keyring, password manager, Vault, or another provider without changing the names the application reads.
`.env` can remain for ordinary local settings. Existing applications can keep environment-variable delivery while they migrate. Applications using an SDK or file-based delivery can remove secrets from their process environments.
**SecretSpec aims to eliminate environment variables for secrets altogether.**
# GitHub Actions
> Resolve secrets from secretspec.toml in GitHub Actions, Forgejo Actions, and other CI systems
In a GitHub or Forgejo Actions job, `secretspec-action` installs the CLI and runs `secretspec export --format gha`, which masks every value in the runner log and appends `KEY=value` to `$GITHUB_ENV`. Every later step, including third-party actions, then sees the secrets as ordinary environment variables.
```yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: cachix/secretspec-action@main
with:
profile: production
- run: ./deploy.sh
```
A missing required secret fails the step before the job runs anything with an incomplete environment.
## Fetching from a secret manager
[Section titled “Fetching from a secret manager”](#fetching-from-a-secret-manager)
For secrets kept in a dedicated store, resolve them on the runner with the matching provider, shown here with [Vault or OpenBao](/providers/vault/). Other stores plug in the same way with their own credentials.
Grant the job `id-token: write` and select `?auth=jwt`. Pinning a `role`, as in the example below, keeps the workflow independent of server configuration. Starting with SecretSpec 0.18, the role may instead come from the JWT auth mount’s configured `default_role`. Vault exchanges the runner’s OIDC token for a client token, so nothing is stored on the platform.
```yaml
- uses: cachix/secretspec-action@main
with:
profile: production
provider: vault://vault.example.com:8200/secret?auth=jwt&role=ci
```
Without an OIDC identity to draw on, select `?auth=approle` instead and pass `VAULT_ROLE_ID` and `VAULT_SECRET_ID` as CI secrets.
```yaml
- uses: cachix/secretspec-action@main
with:
profile: production
provider: vault://vault.example.com:8200/secret?auth=approle
env:
VAULT_ROLE_ID: ${{ secrets.VAULT_ROLE_ID }}
VAULT_SECRET_ID: ${{ secrets.VAULT_SECRET_ID }}
```
## Other CI systems
[Section titled “Other CI systems”](#other-ci-systems)
`secretspec-action` is a convenience wrapper around commands that work anywhere the CLI is installed.
* `secretspec run -- ` runs a single command with the secrets confined to its environment.
* `secretspec export` writes the resolved secrets to stdout for a tool that cannot be wrapped, such as a containerized pipeline. `eval "$(secretspec export)"` loads them into the current shell, while `--format dotenv` and `--format json` feed other consumers.
Both resolve through the same provider chain and fail on a missing required secret.
# Comparison
> See how SecretSpec and its providers work together
SecretSpec is the application-facing layer of a secrets system. It defines what an application needs, resolves those requirements across environments, and delivers the resulting values through its CLI and [provider-independent SDKs](/sdk/overview/). Providers connect that layer to concrete value sources. Depending on the backend, they can add secure storage, identity, access control, availability, and provider-native operations.
This separation keeps [`secretspec.toml`](/concepts/declarative/) portable. A developer can use the system keyring, CI can supply environment variables, and production can use Vault or a cloud secret manager without changing the application’s secret contract.
```text
secretspec.toml SecretSpec Provider
what the app needs → resolve · check · deliver ← provider-backed values
route · audit source · access
```
## Division of responsibility
[Section titled “Division of responsibility”](#division-of-responsibility)
| Responsibility | SecretSpec | Providers augment SecretSpec with |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Application secret contract | Declares names, descriptions, [requirements and defaults](/reference/configuration/#secret-variable-options), [generated values](/concepts/generation/), and [composed values](/concepts/composed-secrets/) | Supply provider-backed values named by that contract |
| Environments | Defines portable [profiles](/concepts/profiles/), [configuration inheritance](/concepts/inheritance/), and profile-specific requirements | Add provider-native projects, vaults, paths, or environments |
| Preflight validation | [`check`](/reference/cli/#check) validates required secrets and configuration before the application starts | Report whether a requested value exists or can be accessed |
| Provider selection | Routes each secret independently through [provider aliases and ordered fallback chains](/concepts/providers/fallback/) | Supply concrete sources and destinations |
| Existing provider-native secrets | Uses [secret references](/concepts/references/) to give an existing value a stable, application-facing name | Interpret provider-specific coordinates such as vault, item, field, path, or version |
| Application delivery | Resolves secrets through the [CLI](/reference/cli/), [exports environments](/reference/cli/#export), [starts child processes](/reference/cli/#run), and [manages temporary files](/reference/configuration/#as_path-option) | Supply values through provider APIs or clients |
| Application SDKs | Offers one [provider-independent resolver](/sdk/overview/#one-resolver-thin-clients) with a shared [runtime API](/sdk/overview/#the-runtime-api) and [typed access](/sdk/overview/#typed-access) across supported programming languages | Vendor SDKs, when available, remain backend-specific; applications do not need to integrate them directly |
| Audit | Records [local, metadata-only access events](/concepts/audit/) by default, including application context and optional reason | Add centralized, provider-side access records where supported and configured |
| Encryption at rest | Delegates protection of provider-backed values to the selected provider | Protect values when the backend supports encryption; dotenv and environment providers add no at-rest encryption |
| Identity and access policy | Uses the credentials available for the selected provider, including [credentials sourced from another provider](/reference/provider-credentials/) | Enforce users, roles, service identities, policies, and sharing |
| Availability and retention | Delegates these guarantees for provider-backed values | May provide synchronization, replication, versions, backup, or retention, depending on the provider |
| Dynamic secrets and credential rotation | [Roadmap](https://github.com/cachix/secretspec/issues/11); not currently available and has no assigned target release | Provide native lifecycle features where available; use them outside SecretSpec today |
The distinction is intentional: SecretSpec provides portable application semantics, while each provider determines how its provider-backed values are stored, protected, and operated. Some providers, such as dotenv and environment variables, intentionally provide fewer safeguards. SecretSpec’s [default audit log](/concepts/audit/) complements provider logs by recording the project, profile, secret name, outcome, actor, and reason seen by the application workflow. It is a size-bounded, best-effort local log, not a replacement for central compliance records.
## Supported providers
[Section titled “Supported providers”](#supported-providers)
See [Available providers](/concepts/providers/#available-providers) for the provider comparison, including storage backend, read and write support, encryption at rest, and TPM-backed keys.
Providers can be mixed within one project. For example, an application can read a shared credential from 1Password in the production profile, read the same secret from the system keyring in the development profile, and accept a deployment token from the environment in CI. A secret can also define an ordered fallback chain, which tries the next provider when an earlier provider does not return the value. SecretSpec keeps those storage decisions outside the application’s code.
# Audit Logging
> A local, append-only record of every secret access for after-the-fact review
secretspec records every secret access to a local audit log so you can review, after the fact, **what** secret was accessed, **when**, by **whom**, with what **reason, if supplied**, which software integration called SecretSpec (0.20+), and what the **outcome** was. Auditing is **on by default**.
Secret values are never written to the log. Only metadata is recorded, and any credentials embedded in a provider URI are redacted.
## Where the log lives
[Section titled “Where the log lives”](#where-the-log-lives)
By default the log is written to the per-user state directory, one entry per line in [JSON Lines](https://jsonlines.org/) format:
| Platform | Default path |
| -------- | ------------------------------------- |
| Linux | `~/.local/state/secretspec/audit.log` |
| macOS | `~/.local/state/secretspec/audit.log` |
(secretspec follows the XDG state-directory convention on macOS too, matching where it keeps its config, so the path is the same as on Linux. Set `[audit] path` to override it.)
The file is created with owner-only permissions (`0600` on Unix), inside an owner-only directory (`0700`). The first time secretspec writes to it, it prints a one-time note telling you where the log is and how to turn it off.
## What a record looks like
[Section titled “What a record looks like”](#what-a-record-looks-like)
```json
{
"v": 1,
"id": "386987e6-291f-4e8f-a08b-73db9d80897b",
"ts": "2026-06-04T17:04:00.893Z",
"session_id": "d59e0f0f-ed2f-456f-a2b6-be25a24b7ec7",
"seq": 0,
"action": "get",
"project": "my-app",
"profile": "production",
"key": "DATABASE_URL",
"provider": "keyring://",
"outcome": "found",
"reason": "deploy web frontend",
"caller": {
"name": "git",
"version": "2.51.0",
"operation": "credential_get",
"resource": "github.com"
},
"actor": { "user": "alice", "agent": "claude-code", "is_agent": true },
"version": "0.20.0"
}
```
| Field | Meaning |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `v` | Schema version of the record |
| `id` | Unique id for this event |
| `ts` | RFC 3339 UTC timestamp |
| `session_id` | Shared by every event from one `secretspec` invocation |
| `seq` | Monotonic sequence within that invocation |
| `action` | The operation: `get`, `set`, `check`, `run`, `import`, `export`, `cache_clear` / `cache_refresh` (0.17+), or `delete` (0.18+) |
| `project` / `profile` | The project and profile in effect |
| `scope` | The named scope for a scoped `check`, `run`, or `export`; omitted otherwise (SecretSpec 0.17+) |
| `key` | The secret name for single-secret actions (`get`/`set`, and `delete` in 0.18+); never its value |
| `keys` | The set of secret names for bulk actions (`check`/`run`/`import`/`export`) |
| `command` | For `run`, the executed program (argv\[0] only — never its arguments, which may contain secrets) |
| `provider` | The provider URI that served the access, with credentials redacted |
| `outcome` | `found`, `missing`, `default`, `written`, `deleted` (0.17+ cache clear), `started` (a `run` launched its command), or `error` |
| | A cached route writing its local entry is recorded as `cache_refresh`/`written`, never as `set`: no authoritative store was written. Dropping an entry — `cache clear`, or an entry a write superseded — is `cache_clear`/`deleted`. |
| `error_kind` | A non-sensitive tag when `outcome` is `error` |
| `reason` | The reason supplied via `--reason` / `SECRETSPEC_REASON` / the SDK, if any |
| `caller` | Caller-asserted software integration context: `name`, and optional `version`, `operation`, and non-secret `resource` (SecretSpec 0.20+) |
| `actor` | The OS user, the detected coding agent (if any), and whether this is an agent session |
This pairs naturally with the [`require_reason`](/reference/configuration/#requiring-a-reason-for-secret-access) policy: when that policy applies, SecretSpec requires the caller to state *why* before proceeding and records the supplied reason alongside the access.
Caller context answers *what software* requested access; `reason` answers *why the user* requested it. Caller context is informational, is not an authenticated identity, and never satisfies `require_reason`. Integrations must not place a credential or secret value in any caller field.
## Reading the log
[Section titled “Reading the log”](#reading-the-log)
The log is plain JSON Lines, so any tool works (`cat`, `tail -f`, `jq`). The [`secretspec audit`](/reference/cli/#audit) command reads it for you with filters and a readable summary:
```bash
# Last 20 entries, formatted
$ secretspec audit -n 20
# Only `run` events for one project
$ secretspec audit --project my-app --action run
# Raw JSON Lines, piped to jq
$ secretspec audit --json | jq 'select(.outcome == "missing")'
```
## Size cap
[Section titled “Size cap”](#size-cap)
The log is a single file capped at **1 MiB** by default. When it reaches the cap it is truncated and started fresh, so disk usage stays bounded without any log rotation to manage. This makes the log a size-bounded recent record rather than a complete, permanent history — it is not intended to satisfy long-term compliance retention on its own. Forward it to a central system if you need that.
## Reliability
[Section titled “Reliability”](#reliability)
Auditing never blocks secret access. If the log cannot be written (for example, a read-only filesystem), secretspec prints a `warning:` to stderr and continues — your `get`, `set`, and `run` still work.
## Configuration
[Section titled “Configuration”](#configuration)
Auditing is a per-machine concern, so it is configured in your **user-global config** (`~/.config/secretspec/config.toml`) under the top-level `[audit]` table — not in the project’s `secretspec.toml`. This means a repository you clone cannot turn off or redirect your audit log. See the [configuration reference](/reference/configuration/#audit-logging) for all options. To turn it off:
\~/.config/secretspec/config.toml
```toml
[audit]
enabled = false
```
# Composed Secrets
> Derive read-only values from other declared secrets with strict templates
**New in version 0.16**
Composed secrets derive one exported value from other secrets in the active profile. They are useful for connection strings, command arguments, and other formats whose components should remain independently stored:
```toml
[profiles.default]
DB_USER = { description = "Database user" }
DB_PASSWORD = { description = "Database password" }
DB_HOST = { description = "Database host" }
DATABASE_URL = {
description = "PostgreSQL connection string",
composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app"
}
```
`DB_USER`, `DB_PASSWORD`, and `DB_HOST` resolve through their ordinary providers. `DATABASE_URL` is then rendered in memory and exported alongside them. The composed result is never read from or written to a provider.
## Static dependency graph
[Section titled “Static dependency graph”](#static-dependency-graph)
Every `${UPPERCASE_NAME}` must name a secret declared in the effective profile. Reference names must match `[A-Z][A-Z0-9_]*`. SecretSpec validates the complete graph while loading `secretspec.toml`, before accessing a provider:
* declaration order does not matter;
* a composition may reference another composition;
* unknown references are errors;
* dependency cycles are errors.
```toml
[profiles.default]
USER = { description = "Database user" }
PASSWORD = { description = "Database password" }
HOST = { description = "Database host" }
AUTHORITY = { description = "Database authority", composed = "${USER}:${PASSWORD}" }
DATABASE_URL = { description = "Database URL", composed = "postgres://${AUTHORITY}@${HOST}/app" }
```
This differs deliberately from dotenv expansion, where behavior can depend on file order, process environment, and how a particular parser handles undefined or recursive variables.
## Template syntax
[Section titled “Template syntax”](#template-syntax)
Composition is a small, strict language:
| Syntax | Meaning |
| ------------------- | ------------------------------------------- |
| `${UPPERCASE_NAME}` | Insert one declared secret’s exported value |
| `$$` | Insert a literal `$` |
For example, `$${EXTERNAL_NAME}` renders the literal text `${EXTERNAL_NAME}` without treating it as a SecretSpec reference.
The following are intentionally unsupported:
* lowercase or mixed-case references such as `${password}` or `${Password}`;
* shell-style expressions such as `${NAME:-fallback}`;
* ambient environment-variable lookup;
* command substitution;
* recursive expansion.
Plain `{` and `}` are literal, so JSON objects, CSS blocks, and regular-expression quantifiers do not require brace escaping. Substitution is one pass. If `PASSWORD` contains the literal text `${HOST}`, inserting `${PASSWORD}` produces `${HOST}`; it is not scanned again. This keeps secret bytes opaque and prevents values from unexpectedly becoming executable template syntax.
## Missing, empty, and optional values
[Section titled “Missing, empty, and optional values”](#missing-empty-and-optional-values)
Missing and empty are different:
* an empty dependency inserts an empty string;
* a missing dependency makes a required composition missing;
* when the composed secret sets `required = false`, a missing dependency omits the composed result instead.
SecretSpec never silently replaces a missing reference with empty text. Interactive `secretspec check` prompts for the unresolved provider-backed dependencies, not for the derived result.
## Read-only behavior
[Section titled “Read-only behavior”](#read-only-behavior)
A composed secret cannot also declare `default`, `providers`, `ref`, `type`, or enabled `generate`. These fields would give the same name two competing value sources.
* `get` resolves the target’s transitive dependencies and prints the result;
* `set` rejects the composed name as read-only;
* `import` skips composed names because there is no stored value to copy;
* `check`, `run`, `export`, and SDK resolution include the composed value like any other resolved secret.
## Profiles and inheritance
[Section titled “Profiles and inheritance”](#profiles-and-inheritance)
References are checked against the effective profile after `default` profile inheritance. A profile may override the template while inheriting component declarations:
```toml
[profiles.default]
DB_USER = { description = "Database user" }
DB_PASSWORD = { description = "Database password" }
DB_HOST = { description = "Database host" }
DATABASE_URL = { description = "Database URL", composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app" }
[profiles.development]
DATABASE_URL = { composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app_dev" }
```
Profile-level storage defaults do not apply to composed secrets, because their source is the dependency graph rather than a provider. Profile-level `required` defaults still apply.
## Paths and encoding
[Section titled “Paths and encoding”](#paths-and-encoding)
When a dependency uses `as_path = true`, its exported temporary-file path is inserted. Setting `as_path = true` on the composed secret instead writes the final rendered value to a temporary file.
Composition performs raw string concatenation. It does not URL-encode or JSON-encode values: SecretSpec cannot infer whether a component is a username, password, host, path, query parameter, or structured value. Store each component in the representation required by the destination format. To export the resolved secret map as safely encoded JSON, use `secretspec export --format json`.
See the [`composed` configuration reference](/reference/configuration/#composed-secrets) for the field-level constraints.
# Declarative Configuration
> Understanding secretspec.toml and its declarative approach
SecretSpec uses `secretspec.toml` to declare what secrets your application needs, separating requirements from storage mechanisms for portability across environments.
## Basic Structure
[Section titled “Basic Structure”](#basic-structure)
```toml
[project]
name = "my-app"
revision = "1.0"
extends = ["../shared/common"] # Optional: inherit from other configs
[profiles.default]
DATABASE_URL = { description = "PostgreSQL connection string", required = true }
API_KEY = { description = "External API key", required = true }
SESSION_SECRET = { description = "Session signing secret", required = true, type = "password", generate = true }
```
## Secret Declarations
[Section titled “Secret Declarations”](#secret-declarations)
Each secret is declared with configuration options:
```toml
SECRET_NAME = {
description = "Human-readable explanation", # Required: shown in prompts
required = true, # Optional: defaults to true
default = "value" # Optional: fallback if not set
}
```
**Options:**
* `description`: Explains the secret’s purpose (required in the `default` profile; profile overrides inherit it when omitted)
* `required`: Whether the secret must be provided (default: `true`)
* `default`: Fallback value for optional secrets
* `composed` (0.16+): Derive a read-only value from other declared secrets (see [Composed Secrets](/concepts/composed-secrets/) for the strict template and dependency semantics)
* `type`: Secret type for auto-generation (`password`, `hex`, `base64`, `uuid`, `command`, `rsa_private_key`, `openpgp_private_key` (0.21+), and `ssh_private_key` (0.21+))
* `generate`: Enable auto-generation when the secret is missing (`true` or a table with options)
* `prompt` (0.19+): Securely ask for a missing value during `secretspec run` and let the selected provider decide whether to save the answer
## Related Concepts
[Section titled “Related Concepts”](#related-concepts)
* [Configuration Inheritance](/concepts/inheritance/) lets projects share common secret definitions via the `extends` field
* [Secret Generation](/concepts/generation/) auto-creates passwords, tokens, and keys when secrets are missing
* [Run prompts (0.19+)](/reference/configuration/#prompt-on-missing-during-run-019) provision stored secrets on first use, or remain invocation-only with `null`
* [Composed Secrets (0.16+)](/concepts/composed-secrets/) derive values from other declared secrets without dotenv or shell expansion
## Best Practices
[Section titled “Best Practices”](#best-practices)
1. **Descriptive names**: Use `STRIPE_API_KEY` instead of generic `API_KEY`
2. **Clear descriptions**: Help developers understand each secret’s purpose
3. **Sensible defaults**: Provide development defaults, require production values
4. **Modular inheritance**: Create reusable base configurations for common patterns
## Complete Example
[Section titled “Complete Example”](#complete-example)
```toml
[project]
name = "web-api"
revision = "1.0"
extends = ["../shared/base", "../shared/auth"]
[profiles.default]
# Inherits DATABASE_URL, INTERNAL_API_KEY from base
# Inherits JWT_SECRET, SESSION_SECRET from auth
# Service-specific additions:
STRIPE_API_KEY = { description = "Stripe payment API", required = true }
REDIS_URL = { description = "Redis cache connection", required = true }
PORT = { description = "Server port", required = false, default = "3000" }
```
# Secret Generation
> Automatically generating passwords, tokens, and keys for missing secrets
**New in version 0.7**
Secrets can be declared with `type` and `generate` to be auto-generated when missing. This is useful for passwords, tokens, and keys that do not need to be shared across developers.
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
```toml
[profiles.default]
DB_PASSWORD = { description = "Database password", type = "password", generate = true }
API_TOKEN = { description = "API token", type = "hex", generate = { bytes = 32 } }
SESSION_KEY = { description = "Session key", type = "base64", generate = { bytes = 64 } }
REQUEST_ID = { description = "Request ID prefix", type = "uuid", generate = true }
```
## Generation Types
[Section titled “Generation Types”](#generation-types)
| Type | Default Output | Options |
| ----------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `password` | 32 alphanumeric chars | `length` (int), `charset` (`"alphanumeric"` or `"ascii"`) |
| `hex` | 64 hex chars (32 bytes) | `bytes` (int) |
| `base64` | 44 chars (32 bytes) | `bytes` (int) |
| `uuid` | UUID v4 (36 chars) | none |
| `command` | stdout of command | `command` (string, required) |
| `rsa_private_key` | 2048-bit RSA private key (PKCS1 PEM) | `bits` (int) |
| `openpgp_private_key` (0.21+) | ASCII-armored OpenPGP transferable secret key | `user_id` (required), `algorithm` (`"ed25519"` or `"rsa"`), `bits` (RSA only), `capabilities` (`["sign"]`, `["encrypt"]`, or both) |
| `ssh_private_key` (0.21+) | Unencrypted OpenSSH Ed25519 private key | `algorithm` (`"ed25519"` or `"rsa"`), `bits` (RSA only), `comment` (string) |
### Command type
[Section titled “Command type”](#command-type)
The `command` type runs a shell command and uses its stdout as the generated value:
```toml
MONGO_KEY = { description = "MongoDB keyfile", type = "command", generate = { command = "openssl rand -base64 765" } }
```
`command` requires `generate = { command = "..." }` rather than just `generate = true`.
### OpenPGP private keys
[Section titled “OpenPGP private keys”](#openpgp-private-keys-021)
**New in version 0.21**
`openpgp_private_key` generates a GnuPG-compatible OpenPGP v4 key entirely in process; neither `gpg` nor another executable is required. Its modern default uses an Ed25519 certification-only primary key and puts routine operations on separate Ed25519 signing and Curve25519 encryption subkeys.
The User ID is required. With no `capabilities`, SecretSpec creates both signing and encryption subkeys:
```toml
[profiles.default]
GENERAL_KEY = { description = "Service OpenPGP key", type = "openpgp_private_key", generate = { user_id = "Service Bot " } }
# A signing-only key has no encryption subkey.
RELEASE_KEY = { description = "Release signing key", type = "openpgp_private_key", generate = { user_id = "Release Bot ", capabilities = ["sign"] } }
# RSA is available for consumers that require it; 3072 bits is the default.
LEGACY_KEY = { description = "Legacy-compatible OpenPGP key", type = "openpgp_private_key", generate = { user_id = "Legacy Bot ", algorithm = "rsa", bits = 4096 } }
```
`capabilities` must be a non-empty list containing `"sign"`, `"encrypt"`, or both without duplicates. `algorithm` defaults to `"ed25519"`. Selecting `"rsa"` uses RSA for the primary key and every requested subkey; `bits` defaults to 3072 and accepts values from 2048 through 8192. `bits` is invalid with `"ed25519"`.
The result is one `-----BEGIN PGP PRIVATE KEY BLOCK-----` value that can be imported by GnuPG and other OpenPGP tools. It has no OpenPGP passphrase and no expiration; protect it with an encrypted provider and rotate it according to the consuming system’s policy. Set `as_path = true` when a command needs a temporary key file rather than the armored value in an environment variable.
### SSH private keys
[Section titled “SSH private keys”](#ssh-private-keys-021)
**New in version 0.21**
`ssh_private_key` generates an unencrypted OpenSSH private key entirely in process. `generate = true` uses Ed25519, the sensible default for new SSH keys:
```toml
[profiles.default]
DEPLOY_KEY = { description = "Deployment SSH key", type = "ssh_private_key", generate = true }
# RSA is available for compatibility; 3072 bits is the default.
LEGACY_DEPLOY_KEY = { description = "Legacy deployment key", type = "ssh_private_key", generate = { algorithm = "rsa", bits = 4096, comment = "deploy@example.com" } }
```
RSA sizes from 2048 through 8192 bits are accepted. `bits` is invalid with Ed25519. `comment` is optional and cannot contain control characters. Generated keys are not passphrase-encrypted, so store them in an encrypted provider. Set `as_path = true` when a command needs the key in a temporary file rather than in an environment variable.
## How it works
[Section titled “How it works”](#how-it-works)
* Generation only triggers when a secret is **missing**. Existing secrets are never overwritten.
* Generated values are stored via the secret’s configured provider (or the default provider).
* Subsequent runs find the stored value and skip generation (idempotent).
* The `null` provider (0.19+) instead returns a fresh generated value for only the current resolution.
* `generate` and `default` cannot both be set on the same secret.
* Setting `type` without `generate` is informational only and does not trigger auto-generation.
## Ephemeral generation with null
[Section titled “Ephemeral generation with null”](#ephemeral-generation-with-null-019)
**New in version 0.19**
Use `providers = ["null"]` when the value should be generated on demand and never written to provider storage:
```toml
[profiles.default]
SESSION_SECRET = { description = "Per-run session secret", type = "base64", generate = { bytes = 32 }, providers = ["null"] }
```
One materializing resolution receives one value. The next `run`, `get`, `check`, or SDK value-carrying resolution receives a new one. Value-free reports describe the value as generated without minting it. Use a writable provider instead when another process or later invocation must retrieve the same value.
## Example
[Section titled “Example”](#example)
```toml
[profiles.default]
# Auto-generated on first run, reused after that
DB_PASSWORD = { description = "Database password", type = "password", generate = true }
# Custom length and character set
ADMIN_PASSWORD = { description = "Admin password", type = "password", generate = { length = 64, charset = "ascii" } }
# 64-byte key encoded as base64
ENCRYPTION_KEY = { description = "Encryption key", type = "base64", generate = { bytes = 64 } }
# RSA private key (default 2048-bit)
JWT_SIGNING_KEY = { description = "JWT signing key", type = "rsa_private_key", generate = true }
# RSA private key with custom key size
TLS_KEY = { description = "TLS private key", type = "rsa_private_key", generate = { bits = 4096 } }
# OpenPGP signing key (requires SecretSpec 0.21+)
RELEASE_KEY = { description = "Release signing key", type = "openpgp_private_key", generate = { user_id = "Release Bot ", capabilities = ["sign"] } }
# OpenSSH Ed25519 private key (requires SecretSpec 0.21+)
DEPLOY_KEY = { description = "Deployment SSH key", type = "ssh_private_key", generate = true }
# Informational type only, no generation
EXTERNAL_API_KEY = { description = "Provided by vendor", type = "password" }
```
See the [configuration reference](/reference/configuration/#secret-generation) for the full specification.
# Configuration Inheritance
> Sharing common secret definitions across projects with extends
SecretSpec supports sharing common secrets across projects through the `extends` field in `[project]`. This avoids duplicating secret definitions in monorepos or multi-service setups.
## Basic Example
[Section titled “Basic Example”](#basic-example)
A shared base configuration:
shared/common/secretspec.toml
```toml
[project]
name = "common"
[profiles.default]
DATABASE_URL = { description = "Main database", required = true }
INTERNAL_API_KEY = { description = "Internal service API key", required = true }
```
A project that extends it:
myapp/secretspec.toml
```toml
[project]
name = "myapp"
extends = ["../shared/common"]
[profiles.default]
DATABASE_URL = { description = "MyApp database", required = true } # Override
API_KEY = { description = "External API key", required = true } # Add new
```
## Monorepo Structure
[Section titled “Monorepo Structure”](#monorepo-structure)
```plaintext
monorepo/
├── shared/
│ ├── base/secretspec.toml # Common secrets
│ └── database/secretspec.toml # DB-specific (extends base)
└── services/
├── api/secretspec.toml # API service (extends database)
└── frontend/secretspec.toml # Frontend (extends base)
```
## Multiple Inheritance
[Section titled “Multiple Inheritance”](#multiple-inheritance)
A project can extend multiple configurations. Later sources take precedence over earlier ones:
```toml
[project]
name = "api-service"
extends = ["../../shared/base", "../../shared/database", "../../shared/auth"]
```
## Rules
[Section titled “Rules”](#rules)
* Child definitions completely replace parent definitions for the same secret
* Later sources in `extends` override earlier ones
* Shared ancestors are applied once, so diamond-shaped inheritance is supported
* Each profile is merged independently
* Profile `[defaults]` inherit field by field across source files
* The `inherit` profile-default field (0.19+) follows that same `extends` precedence. A child profile keeps an inherited `inherit = false` unless a later source explicitly sets it to `true`.
* A child `[scopes.]` completely replaces the parent scope of the same name — its `secrets` list wins outright; the two lists are **not** unioned. Scopes defined only in a parent are inherited. (Whole-value replacement is the safe default for an allowlist: extending a config cannot silently widen a scope the parent narrowed.) Available from SecretSpec 0.17.
* Paths are relative to the containing `secretspec.toml` file
# Concepts Overview
> How SecretSpec's core concepts work together
SecretSpec is built around three core ideas that separate concerns and keep your secrets portable across environments.
## Declare what you need
[Section titled “Declare what you need”](#declare-what-you-need)
A [`secretspec.toml`](/concepts/declarative/) lists the abstract secrets your project depends on, with descriptions, defaults, and whether they are required. This file lives in version control so every developer and CI system sees the same requirements.
## Use profiles for environments
[Section titled “Use profiles for environments”](#use-profiles-for-environments)
[Profiles](/concepts/profiles/) let you vary secret requirements per environment. A `production` profile can enforce strict requirements while a `development` profile provides safe defaults. Non-default profiles inherit from `default` when it exists, so you only specify what changes; SecretSpec 0.19+ also supports standalone profiles that opt out of this inheritance.
## Store secrets anywhere with providers
[Section titled “Store secrets anywhere with providers”](#store-secrets-anywhere-with-providers)
[Providers](/concepts/providers/) are pluggable backends (keyring, dotenv, 1Password, Vault, etc.) that handle actual storage and retrieval. The same `secretspec.toml` works regardless of where secrets are stored, and you can swap providers without changing your project configuration.
## How they connect
[Section titled “How they connect”](#how-they-connect)
```plaintext
secretspec.toml Profile selected Provider resolves
(what you need) --> (which requirements) --> (where to get values)
```
1. You declare secrets in `secretspec.toml`
2. The active profile determines which secrets are required and what defaults apply
3. The provider retrieves (or stores) the actual values
Each concern is independent: you can change your storage backend without touching profile definitions, or add a new environment without modifying provider configuration.
## Additional concepts
[Section titled “Additional concepts”](#additional-concepts)
* [Configuration Inheritance](/concepts/inheritance/) lets projects share common secret definitions via `extends`
* [Scopes (0.17+)](/concepts/scopes/) let each service or task resolve only its declared subset of a profile
* [Secret Generation](/concepts/generation/) auto-creates passwords, tokens, and keys when secrets are missing
* [Composed Secrets (0.16+)](/concepts/composed-secrets/) derive read-only values from other declared secrets
# Profiles
> Managing environment-specific secret requirements with profiles
## What Are Profiles?
[Section titled “What Are Profiles?”](#what-are-profiles)
Profiles are named configurations that define how secrets behave in different environments. They specify which secrets are required vs optional, provide safe defaults for development, and enforce strict requirements for production.
A key feature of profiles is inheritance: non-default profiles inherit secrets from the `default` profile when it exists. This means you only need to override the specific properties that change between related environments. SecretSpec 0.19+ also lets an unrelated profile opt out and remain standalone.
If a manifest omits `default`, callers must select an existing profile with `--profile`, `SECRETSPEC_PROFILE`, or their user config; the final fallback name is still `default`.
## Basic Usage
[Section titled “Basic Usage”](#basic-usage)
Define profiles in your `secretspec.toml`:
```toml
[profiles.default]
DATABASE_URL = { description = "PostgreSQL connection", required = true }
API_KEY = { description = "External API key", required = true }
[profiles.development]
# Inherits DATABASE_URL and API_KEY from default, only overriding their requirements
DATABASE_URL = { required = false, default = "postgresql://localhost:5432/myapp_dev" }
API_KEY = { required = false, default = "dev-key-12345" }
DEBUG = { description = "Enable debug mode", required = false, default = "true" }
[profiles.production]
# Inherits all secrets from default profile
# Only need to add production-specific secrets
SENTRY_DSN = { description = "Error tracking", required = true }
```
## Selecting Profiles
[Section titled “Selecting Profiles”](#selecting-profiles)
SecretSpec resolves the active profile in this order:
1. **Command line**: `--profile production` (highest priority)
2. **Environment variable**: `SECRETSPEC_PROFILE=staging`
3. **User config**: Default profile in `~/.config/secretspec/config.toml`
4. **Fallback**: `default` profile
```bash
# Use specific profile
$ secretspec check --profile development
✓ DATABASE_URL - PostgreSQL connection (using default)
✓ API_KEY - External API key (using default)
# Set via environment
$ export SECRETSPEC_PROFILE=production
$ secretspec run -- npm start
```
## Profile Inheritance in Detail
[Section titled “Profile Inheritance in Detail”](#profile-inheritance-in-detail)
When using profiles, inheritance works as follows:
1. **Base definition in default**: Define all your secrets with their descriptions and base requirements in the `default` profile
2. **Override only what changes**: Other profiles only need to specify the properties that differ from default
3. **Field-level overrides**: Most explicitly set properties replace the corresponding property from `default`, while omitted properties continue to inherit
4. **Profile-specific secrets**: Secrets not in the default profile can be added to any profile
### Standalone profiles
[Section titled “Standalone profiles”](#standalone-profiles-019)
**New in version 0.19**
Set `inherit = false` in a non-default profile’s `defaults` table when its secret set is unrelated to `[profiles.default]`:
```toml
[profiles.default]
DATABASE_URL = { description = "Development database", default = "sqlite://./dev.db" }
API_KEY = { description = "Development API key" }
[profiles.production]
# Inherits both default declarations and overrides only what changes.
DATABASE_URL = { required = true }
[profiles.deployment.defaults]
inherit = false # SecretSpec 0.19+
[profiles.deployment]
# Does not inherit DATABASE_URL, API_KEY, or any of their fields.
DEPLOY_TOKEN = { description = "Deployment credential", required = true }
```
The setting disables both automatic inclusion of default secrets and field-by-field inheritance for secrets explicitly redeclared in the standalone profile. Omitting it preserves the existing inheritance behavior. A standalone profile must declare at least one secret.
### Switching reference models
[Section titled “Switching reference models”](#switching-reference-models-019)
**New in version 0.19**
Legacy `ref` and provider-scoped `refs` are alternative forms of one inherited address-model setting. Declaring either one in a profile replaces both forms from `[profiles.default]`; omitting both continues to inherit the default profile’s form. This lets a profile switch from one route-wide address to provider-specific addresses without retaining an invalid mixture of both:
```toml
[providers]
legacy = "onepassword://Legacy"
production = "onepassword://Production"
[profiles.default]
API_KEY = { description = "API key", providers = ["legacy"], ref = { item = "shared-api", field = "token" } }
[profiles.production]
# Inherits the description, but replaces providers and the complete ref/refs choice.
API_KEY = { providers = ["production"], refs = { production = { item = "production-api", field = "credential" } } }
```
The reverse switch also works: a profile’s `ref` replaces inherited `refs`. Within one effective secret, `ref` and `refs` remain mutually exclusive. See [Secret References](/concepts/references/#different-coordinates-per-provider-019) for how each model addresses providers.
## Profiles, Scopes, Providers, and Extends
[Section titled “Profiles, Scopes, Providers, and Extends”](#profiles-scopes-providers-and-extends)
These features solve different dimensions of a configuration:
* A **profile** chooses an environment or context. It controls requiredness, defaults, provider routes, references, and the `{profile}` storage namespace.
* A **scope** selects which secrets one service or task receives from the effective profile. It does not create another environment.
* A secret’s **providers** choose where its value is read and written. Provider chains are also the least-privilege boundary: a process only needs access to the stores used by the secrets in its scope.
* **`extends`** merges separate `secretspec.toml` files. Use it to share manifests across projects, not to express relationships among several profiles in one small manifest.
For an application with `development` and `production` environments plus `app`, `public`, and `deploy` consumers, profiles normally model the two environments, scopes model the three consumers, and per-secret provider chains route each value to the appropriate store.
## Profile-Level Defaults
[Section titled “Profile-Level Defaults”](#profile-level-defaults)
To reduce repetition when multiple secrets in a profile share the same settings, use the `profiles..defaults` section:
```toml
[providers]
prod_vault = "onepassword://Production"
keyring = "keyring://"
[profiles.production.defaults]
providers = ["prod_vault", "keyring"]
required = true
[profiles.production]
DATABASE_URL = { description = "Production DB" }
API_KEY = { description = "API Key" }
SENTRY_DSN = { description = "Error tracking" }
```
Profile defaults apply to all secrets in that profile unless explicitly overridden. In SecretSpec 0.19+, the same table accepts `inherit = false` to make a non-default profile standalone; unlike `required`, `default`, and `providers`, this controls the relationship with `[profiles.default]` rather than supplying a value to each secret. The precedence order is:
1. **Secret-level configuration** (highest priority) — explicit settings in the secret definition
2. **Profile inheritance** — inherited from the default profile when the active profile omits a field
3. **Profile defaults** — from `profiles..defaults`
4. **Project provider defaults** — from `[defaults].providers` in 0.21+
5. **Global defaults** (lowest priority) — from CLI, environment, or global config
This is particularly useful for setting common [provider fallback routes](/concepts/providers/fallback/#ordered-fallback-routes), requirements, or defaults across all secrets in a profile.
## Practical Example
[Section titled “Practical Example”](#practical-example)
A web application with different requirements per environment:
```toml
[project]
name = "web-app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "PostgreSQL connection", required = true }
REDIS_URL = { description = "Redis for caching", required = true }
JWT_SECRET = { description = "JWT signing key", required = true }
[profiles.development]
# Inherits all secrets from default, just adding defaults
DATABASE_URL = { default = "postgresql://localhost:5432/webapp_dev" }
REDIS_URL = { default = "redis://localhost:6379/0" }
JWT_SECRET = { default = "dev-secret-change-in-prod" }
HOT_RELOAD = { description = "Enable hot reload", required = false, default = "true" }
[profiles.production]
# Inherits DATABASE_URL, REDIS_URL, JWT_SECRET from default
# Only adds production-specific secrets
SENTRY_DSN = { description = "Error tracking", required = true }
SSL_CERT = { description = "SSL certificate path", required = true }
```
# Providers
> Choose and configure the storage backends SecretSpec uses for secrets
A provider is a storage backend from which SecretSpec reads secrets and, when supported, writes them. Providers let one `secretspec.toml` describe the secrets an application needs without requiring every environment to use the same secret store.
For example, a developer can use the system keyring, CI can supply environment variables, and production can use a shared password manager or cloud secret manager. The secret definitions stay the same; only their provider configuration changes.
## Provider specifications
[Section titled “Provider specifications”](#provider-specifications)
Anywhere SecretSpec accepts a provider, you can use one of three forms:
* A provider name, such as `keyring` or `env`.
* A provider URI, such as `dotenv://.env.local` or `onepassword://Production`. The URI configures a particular instance of the provider.
* A provider alias, such as `prod_vault`, defined in project or user configuration.
Aliases are useful when a URI is shared by several secrets or should have a meaningful, store-independent name.
secretspec.toml
```toml
[providers]
prod_vault = "onepassword://Production"
[profiles.production]
DATABASE_URL = { description = "Production database", providers = ["prod_vault"] }
```
## Available providers
[Section titled “Available providers”](#available-providers)
| Provider | Storage backend | Read | Write | Encrypted at rest | TPM-backed keys |
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ------ | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| [keyring](/providers/keyring/) | [macOS Keychain](https://support.apple.com/guide/security/keychain-data-protection-secb0694df1a/web), [Windows Credential Manager](https://learn.microsoft.com/windows/win32/secauthn/credentials-management), or [Linux Secret Service](https://gnome.pages.gitlab.gnome.org/libsecret/) | ✓ | ✓ | ✓ | — |
| [kdbx](/providers/kdbx/) (0.17+) | KeePass KDBX file (requires the `kdbx` build feature) | ✓ | KDBX 4 | ✓ | — |
| [dotenv](/providers/dotenv/) | A `.env` file | ✓ | ✓ | ✗ | — |
| [file](/providers/file/) (0.19+) | One plaintext UTF-8 file per secret | ✓ | ✓ | ✗ | — |
| [env](/providers/env/) | Current process environment | ✓ | ✗ | ✗ | — |
| [ejson](/providers/ejson/) (0.20+) | EJSON encrypted file (requires the `ejson` build feature and EJSON CLI) | ✓ | ✗ | ✓ | — |
| [null](/providers/null/) (0.19+) | No storage; uses a manifest default, ephemeral generation, or an ephemeral run prompt | ✗ | ✗ | N/A | — |
| [systemd-credential](/providers/systemd-credential/) (0.17+) | Credentials passed to the current systemd service | ✓ | ✗ | Depends on the unit’s credential source | [Via systemd-creds](https://www.freedesktop.org/software/systemd/man/latest/systemd-creds.html) |
| [fly](/providers/fly/) (0.20+) | Fly.io application secrets through `flyctl` | ✗ | ✓ | ✓ | — |
| [cloudflare](/providers/cloudflare/) (0.20+) | Cloudflare account-level Secrets Store through its REST API | ✗ | ✓ | ✓ | — |
| [pass](/providers/pass/) | Unix `pass` password store | ✓ | ✓ | ✓ | [Via GnuPG](https://gnupg.org/blog/20210315-using-tpm-with-gnupg-2.3.html) |
| [gopass](/providers/gopass/) (0.15+) | `gopass` password store (git-synced, GPG-encrypted) | ✓ | ✓ | ✓ | [Via GnuPG](https://gnupg.org/blog/20210315-using-tpm-with-gnupg-2.3.html) |
| [protonpass](/providers/protonpass/) | Proton Pass | ✓ | ✓ | ✓ | — |
| [passbolt](/providers/passbolt/) (0.19+) | Self-hosted Passbolt through `go-passbolt-cli` | ✓ | ✓ | ✓ | — |
| [onepassword](/providers/onepassword/) | 1Password | ✓ | ✓ | ✓ | — |
| [lastpass](/providers/lastpass/) | LastPass | ✓ | ✓ | ✓ | — |
| [dashlane](/providers/dashlane/) (0.18+) | Dashlane, through the `dcli` CLI | ✓ | ✗ | ✓ | — |
| [keeper](/providers/keeper/) (0.18+) | Keeper Secrets Manager (requires the `keeper` build feature) | ✓ | ✓ | ✓ | — |
| [gcsm](/providers/gcsm/) | Google Cloud Secret Manager (requires the `gcsm` build feature) | ✓ | ✓ | ✓ | — |
| [awssm](/providers/awssm/) | AWS Secrets Manager (requires the `awssm` build feature) | ✓ | ✓ | ✓ | — |
| [awsps](/providers/awsps/) (0.18+) | AWS Systems Manager Parameter Store (requires the `awsps` build feature in 0.18+) | ✓ | ✓ | ✓ (`SecureString`) | — |
| [scaleway](/providers/scaleway/) (0.17+) | Scaleway Secret Manager (requires the `scaleway` build feature) | ✓ | ✓ | ✓ | — |
| [vault](/providers/vault/) | HashiCorp Vault (requires the `vault` build feature) | ✓ | ✓ | ✓ | — |
| [openbao](/providers/openbao/) (0.17+) | OpenBao (requires the `openbao` build feature; 0.16 uses `openbao://` through `vault`) | ✓ | ✓ | ✓ | — |
| [bw](/providers/bw/) (0.18+) | Bitwarden Password Manager via the `bw` CLI (requires the `bw` build feature) | ✓ | ✓ | ✓ | — |
| [bws](/providers/bws/) | Bitwarden Secrets Manager (official `bws` CLI in SecretSpec 0.17+; requires the `bws` build feature) | ✓ | ✓ | ✓ | — |
| [akv](/providers/akv/) | Azure Key Vault (requires the `akv` build feature) | ✓ | ✓ | ✓ | — |
| [aac](/providers/aac/) (0.20+) | Azure App Configuration, including Key Vault-reference resolution (included by default; `aac` feature for custom builds) | ✓ | ✓ | ✓ | — |
| [infisical](/providers/infisical/) (0.16+) | Infisical (requires the `infisical` build feature) | ✓ | ✓ | ✓ | — |
| [age](/providers/age/) (0.17+) | An age-encrypted file (requires the `age` build feature) | ✓ | ✓ | ✓ | — |
| [sops](/providers/sops/) (0.17+) | SOPS-encrypted files (requires the `sops` build feature and SOPS CLI) | ✓ | ✓ | ✓ | Depends on the configured SOPS key service |
| [kubernetes](/providers/kubernetes/) (0.20+) | Kubernetes ConfigMaps and Secrets (requires the `kubernetes` build feature) | ✓ | ✓ | [Secrets can be encrypted at rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/#ensure-all-secrets-are-encrypted) | — |
“TPM-backed keys” means the local key used by the provider can be protected by a [TPM 2.0](https://trustedcomputinggroup.org/resource/tpm-library-specification/) through the provider path SecretSpec uses. Pass and Gopass inherit this capability from GnuPG when its encryption key is moved to the TPM. systemd credentials inherit it from [systemd-creds](https://www.freedesktop.org/software/systemd/man/latest/systemd-creds.html), which seals encrypted credentials to the TPM2 by default when the host has one. [libsecret has an optional TPM2-enabled file backend](https://gnome.pages.gitlab.gnome.org/libsecret/libsecret-tpm2.html), but SecretSpec’s Linux keyring transport uses the Secret Service D-Bus API rather than that file backend. macOS Keychain uses Apple’s Secure Enclave rather than a TPM, and [Windows Vault credentials are not protected by Credential Guard](https://learn.microsoft.com/windows/security/identity-protection/credential-guard/how-it-works). An em dash means SecretSpec has no documented TPM integration for that provider; it does not describe other hardware security used internally by the provider service. Each provider page starts with a minimal working example, then covers setup, project configuration, storage conventions, existing provider-native secrets, and CI/CD where applicable.
## Configure the default provider
[Section titled “Configure the default provider”](#configure-the-default-provider)
Run the interactive configuration command to select the user-global provider SecretSpec uses when a secret has no provider-specific configuration. SecretSpec 0.17+ provides an explicit `global` namespace; the legacy spelling without it remains supported:
```bash
$ secretspec config global init # 0.17+
```
SecretSpec 0.17+ can persist the provider and profile non-interactively:
```bash
$ secretspec config global init --provider env --profile default
```
The resulting user configuration contains a default provider:
\~/.config/secretspec/config.toml
```toml
[defaults]
provider = "keyring"
profile = "development" # Optional default profile
```
Use `--provider` for a one-off override, or `SECRETSPEC_PROVIDER` for commands in the current shell or CI job:
```bash
# Route every secret in this command to a project .env file.
$ secretspec run --provider dotenv -- npm start
# Route every secret in subsequent commands to existing environment variables.
$ export SECRETSPEC_PROVIDER=env
$ secretspec check
```
`SECRETSPEC_PROVIDER` is a whole-resolution override: it replaces every per-secret fallback chain. Integrations such as devenv should only export it when the user explicitly configures a whole-resolution provider override.
When consuming a JSON or SDK resolution, do not feed its top-level `provider` display label back into `SECRETSPEC_PROVIDER`; mixed per-secret routes cannot be represented by one provider string. Only export `SECRETSPEC_PROVIDER` when the user explicitly selected a whole-resolution override. Per-secret `source_provider` remains the authoritative provenance.
A provider URI can configure the selected backend more precisely:
```bash
# Select a specific 1Password vault.
$ secretspec run --provider "onepassword://Development" -- npm start
# Select a specific dotenv file.
$ secretspec run --provider "dotenv:/home/user/work/.env" -- npm test
```
## Configure a project default provider chain (0.21+)
[Section titled “Configure a project default provider chain (0.21+)”](#configure-a-project-default-provider-chain-021)
**New in version 0.21**
Use project `[defaults].providers` when every profile should use the same provider chain unless a profile or secret overrides it. The chain can name a user-global alias, allowing each developer to supply personal provider-native coordinates while the committed manifest stays the same:
secretspec.toml
```toml
[defaults]
providers = ["developer"]
[profiles.staging]
DATABASE_PASSWORD = { description = "Personal staging database password" }
```
\~/.config/secretspec/config.toml
```toml
[defaults.providers.developer]
uri = "awsps://developer@us-east-1"
ref = { item = "/{project}/{profile}/developers/alice/{key}" }
```
The alias’s existing `{project}`, `{profile}`, and `{key}` placeholders expand in the project that selects it. Only `alice` is user-specific. Do not also declare `developer` in the project’s `[providers]` table, because a project alias with the same name takes precedence over the user-global alias.
## Configure provider aliases
[Section titled “Configure provider aliases”](#configure-provider-aliases)
Provider aliases can be declared at either project or user scope:
* Define project aliases in the top-level `[providers]` table in `secretspec.toml`. Commit these aliases so team members and CI use the same mapping.
* Define user aliases in `[defaults.providers]` in `~/.config/secretspec/config.toml`. Use these for personal mappings that should apply across projects.
If both scopes define the same alias, the project alias takes precedence.
secretspec.toml
```toml
[providers]
prod_vault = "onepassword://Production"
shared_vault = "onepassword://Shared"
local = "keyring://"
[profiles.production]
DATABASE_URL = { description = "Production database", providers = ["prod_vault", "local"] }
SENTRY_DSN = { description = "Error reporting", providers = ["shared_vault", "local"] }
```
Provider lists may combine aliases, provider names, and inline provider URIs:
secretspec.toml
```toml
[profiles.production]
DATABASE_URL = { description = "Production database", providers = ["onepassword://Production", "keyring"] }
```
### Alias ref templates
[Section titled “Alias ref templates”](#alias-ref-templates-019)
**New in version 0.19**
A leaf alias can map the logical `{project}`, `{profile}`, and `{key}` into its provider’s native coordinates. This lets every link in a fallback chain—and each side of an import—use a different address:
secretspec.toml
```toml
[providers]
remote = { uri = "onepassword://Production", ref = { item = "{project}-{profile}", field = "{key}" } }
local = { uri = "dotenv://.env", ref = { item = "{key}" } }
[profiles.production]
API_KEY = { description = "API key", providers = ["remote", "local"] }
```
Use per-secret `refs = { alias = { item = "..." } }` for exceptions. See [Secret References](/concepts/references/#different-coordinates-per-provider-019) for precedence, import-only source aliases, and cached-route restrictions.
Use the CLI to manage user-level aliases:
```bash
# SecretSpec 0.17+
$ secretspec config global provider add prod_vault "onepassword://Production"
$ secretspec config global provider list
$ secretspec config global provider remove prod_vault
```
These commands modify only `~/.config/secretspec/config.toml`. Edit the top-level `[providers]` table directly to change project aliases.
## Provider credentials
[Section titled “Provider credentials”](#provider-credentials)
**New in version 0.15**
Some providers need credentials before they can retrieve secrets. Examples include an access token for Bitwarden Secrets Manager, a Vault token or AppRole credentials, a 1Password service account token, and Azure service-principal credentials.
An alias can load these credentials from another provider. This avoids storing long-lived provider credentials in a shell profile or CI variable when a secure store is available.
The [provider credential reference](/reference/provider-credentials/) lists every accepted semantic name, its environment fallbacks, and the SecretSpec version that introduced it.
### Use the convention address
[Section titled “Use the convention address”](#use-the-convention-address)
In an alias’s `credentials` table, map each semantic credential name to the provider that stores it:
secretspec.toml
```toml
[providers]
keyring = "keyring://"
# Read the access token from keyring before connecting to Bitwarden.
bws = { uri = "bws://a9230ec4-5507-4870-b8b5-b3f500587e4c", credentials = { access_token = "keyring" } }
```
A string value such as `"keyring"` is a provider specification. SecretSpec reads the credential from that provider at the conventional `{project}/{profile}/{credential}` address for the active project and profile.
### Use an explicit address
[Section titled “Use an explicit address”](#use-an-explicit-address)
Use a table with `provider` and `ref` when the credential already exists at a specific provider-native address:
secretspec.toml
```toml
[providers.vault_prod]
uri = "vault://secret/myapp?auth=approle"
credentials = {
role_id = { provider = "onepassword", ref = { vault = "Infra", item = "vault-approle", field = "role_id" } },
secret_id = { provider = "onepassword", ref = { vault = "Infra", item = "vault-approle", field = "secret_id" } }
}
```
The `ref` table uses the same provider-native coordinates as a secret [`ref`](/reference/configuration/#secret-references).
### Store provider credentials
[Section titled “Store provider credentials”](#store-provider-credentials)
Use `config provider login` to prompt for every credential declared by an alias and write it to the configured source:
```bash
$ secretspec config provider login bws
Enter access_token for provider 'bws' (source: keyring): ****
✓ stored access_token in keyring at smoke/default/access_token
```
You can also create a user-level alias with a convention-address credential source from the CLI:
```bash
$ secretspec config global provider add bws "bws://project-uuid" --credential access_token=keyring # 0.17+
$ secretspec config provider login bws
```
Provider credentials follow these rules:
* **Configured credentials are authoritative.** When an alias declares a credential, SecretSpec reads its configured source. Providers may still use their conventional environment variables when no explicit credential is supplied.
* **Credentials remain internal.** SecretSpec passes a retrieved credential to the destination provider in memory. It does not export the credential or include it in the environment of a process started by `secretspec run`.
* **Credential chains are one hop.** A source provider cannot require provider credentials of its own. SecretSpec validates this before accessing the provider, preventing dependency cycles.
* **Convention addresses are profile-specific.** A string source uses the active project and profile. Use a `ref` source when multiple projects or profiles should share one provider credential.
* **Names are provider-specific.** The catalog above is exhaustive. Unsupported names are rejected before any source is read.
* **A URI may not carry a credential (0.19+).** A provider URI with a password (`scheme://user:PASSWORD@host`) is rejected, as is a service account token in the `onepassword+token://` userinfo. A URI is committed to `secretspec.toml`, echoed into shell history, and printed by CI, so a credential written there is already disclosed. Use a provider credential or the provider’s environment variable instead.
## Next steps
[Section titled “Next steps”](#next-steps)
* Learn how [Provider fallback](/concepts/providers/fallback/) selects and orders sources.
* Cache slow remote routes and diagnose remaining latency with [Provider caching](/concepts/providers/caching/) (0.17+).
* Review the URI and authentication details for an individual provider in the [Providers](/providers/keyring/) section.
* Learn how [Profiles](/concepts/profiles/) apply provider defaults to an environment.
* Learn how [Secret references](/concepts/references/) separate provider selection from provider-native addresses.
# Provider caching
> Cache slow provider routes in a local secret store
**New in version 0.17**
## The problem
[Section titled “The problem”](#the-problem)
A remote secret read can include authentication, external-process startup, DNS, TCP and TLS setup, and one or more network requests. Those fixed costs can dominate a command that resolves only a few secrets. Separate SecretSpec CLI invocations may pay them again even when the values rarely change.
Latency can also scale with the number of distinct secret addresses. SecretSpec groups compatible reads and providers can batch or parallelize them, but the remote service, proxy, or provider CLI still determines the cost of each read.
## Cache one provider
[Section titled “Cache one provider”](#cache-one-provider-019)
**Changed in version 0.19**
Use a cached fallback alias on SecretSpec 0.17 and 0.18.
Provider caching places a faster local secret store in front of an authoritative provider. A fresh cache entry returns without constructing or contacting the remote provider; a miss reads the remote value and stores it locally for later SecretSpec invocations.
Add `cache` to the remote provider alias and select that same alias normally:
secretspec.toml
```toml
[providers]
local = "keyring://secretspec/cache/{project}/{profile}/{key}"
azure = {
uri = "akv://team-vault",
credentials = { client_secret = "keyring" },
cache = { provider = "local", max_age = "8h" }
}
[profiles.development.defaults]
providers = ["azure"]
```
The alias remains the authoritative provider, so its [provider credentials](/concepts/providers/#provider-credentials) stay next to `uri` and `cache`.
## Cache a fallback route
[Section titled “Cache a fallback route”](#cache-a-fallback-route-017)
**New in version 0.17**
When more than one provider can authoritatively answer, use a route alias. `fallback` lists its providers in read order and `cache.provider` selects the local leaf provider:
secretspec.toml
```toml
[providers]
azure = "akv://team-vault?auth=cli"
env = "env://"
local = "keyring://secretspec/cache/{project}/{profile}/{key}"
remote = {
fallback = ["azure", "env"],
cache = { provider = "local", max_age = "8h" }
}
[profiles.development.defaults]
providers = ["remote"]
```
## Read behavior
[Section titled “Read behavior”](#read-behavior)
SecretSpec reads a cached route in this order:
1. returns a fresh cache entry without constructing or contacting an authoritative provider;
2. on a miss, unusable entry, or cache error, reads the provider URI or tries each `fallback` entry in order;
3. caches the value returned by the authoritative provider that answers.
Cache failures produce warnings but never block the authoritative route. SecretSpec never returns an expired value when all fallbacks fail. It deletes expired, malformed, and route-mismatched entries when found so stale copies do not remain indefinitely in stores without native expiry.
Value-free resolutions such as `check --json`, `check --explain`, and SDK `no_values` requests may read or discard an existing entry, but never populate or refresh one.
## Writes
[Section titled “Writes”](#writes)
Writes and generated values go to the provider URI or the first fallback, then refresh the cache. If the refresh fails, the authoritative write still succeeds and SecretSpec deletes the old cache entry. If deletion also fails, the warning identifies the `cache clear` command to run.
For a cached fallback route, select a leaf provider to bypass the cache for one command:
```bash
$ secretspec check --provider azure
```
In the fallback example, a direct write such as `secretspec set API_KEY --provider azure` invalidates the corresponding cache entry.
## Freshness and invalidation
[Section titled “Freshness and invalidation”](#freshness-and-invalidation)
`max_age` requires a unit: `s`, `m`, `h`, `d`, or `w`; compound durations such as `1h30m` are accepted.
Entries use SecretSpec’s logical `{project}/{profile}/{secret}` address, even when the authoritative secret has a provider-native `ref`. Each entry contains the value, absolute expiration time, originating `max_age`, format version, and a fingerprint of the fallback route and secret reference. Changing the route, reference, or `max_age` invalidates it.
The cache must use a distinct store from every authoritative provider; otherwise, a refresh could overwrite the authoritative secret. SecretSpec rejects such routes during planning. The examples use a separate keyring namespace for `local`.
The cache provider must also support deletion: keyring, pass, gopass, dotenv, age (0.20+), Azure App Configuration (0.20+), or a Vault/OpenBao KV v2 mount. Other providers are rejected during planning. An Azure App Configuration cache must select a different storage identity and address space from every authoritative entry; a separate App Configuration resource is not required.
Clear one entry or every cached entry in the active profile:
```bash
$ secretspec cache clear API_KEY # SecretSpec 0.17+
$ secretspec cache clear --profile production
```
## Store-side expiry
[Section titled “Store-side expiry”](#store-side-expiry)
SecretSpec requests native expiry where supported, using `max_age`. [Vault](/providers/vault/#provider-caching-017) and [OpenBao](/providers/openbao/#provider-caching) set KV v2 `delete_version_after` metadata. This removes the copy on time even if SecretSpec never runs again.
The entry’s absolute expiration time remains the source of truth for freshness on every store; a read at or after that time deletes the entry. Machines sharing a cache should keep their system clocks synchronized. If native expiry cannot be configured, for example because a Vault token lacks metadata access or the mount uses KV v1, SecretSpec refuses the cache write and uses the authoritative route.
## Ownership
[Section titled “Ownership”](#ownership)
Each cache entry records a marker, project, and profile. Because addresses can collide in flat stores such as dotenv, SecretSpec changes only entries whose ownership it can verify.
Unmarked entries and unexpired entries owned by another project or profile are bypassed, not overwritten or deleted. [`cache clear`](/reference/cli/#cache-clear-017) reports them. Any expired SecretSpec entry can be deleted by the project that encounters it because its stored lifetime has ended. A marked but unreadable entry, such as a partial write, can be identified as SecretSpec’s and replaced.
If clearing reports a foreign entry, two configurations are addressing the same place. Give each project a separate store or path, such as the `{project}/{profile}/{key}` path used by `local` above.
## Where cached aliases can be used
[Section titled “Where cached aliases can be used”](#where-cached-aliases-can-be-used)
An inline cached provider alias or cached fallback alias works anywhere a complete route is selected:
* a secret or profile-default `providers` list;
* a project `[defaults].providers` list (0.21+);
* the user-global default provider;
* `SECRETSPEC_PROVIDER`;
* `--provider`.
Because a cached alias defines a complete route, it must be the only entry in a `providers` list. Fallback entries and the cache provider may be aliases, provider names, or URIs, but must resolve to leaf providers; cached aliases cannot be nested.
An inline cached alias can declare credentials next to its `uri`. For a cached fallback route, credentials belong on its leaf aliases rather than on the route alias.
## Security
[Section titled “Security”](#security)
The cache contains the secret value, not just metadata. Use an encrypted provider such as keyring, pass, gopass, or age (0.20+) when values must be encrypted at rest. Dotenv stores entries as plaintext. Native expiry limits how long a copy exists without another SecretSpec run.
## Reference
[Section titled “Reference”](#reference)
* See [`cache clear`](/reference/cli/#cache-clear-017) in the CLI reference.
* Review the [inline cache fields](/reference/configuration/#secretspec-019-inline-provider-cache) (0.19+) or [cached fallback fields](/reference/configuration/#secretspec-017-cached-fallback-alias-values) in the configuration reference.
* Review [Provider fallback](/concepts/providers/fallback/) for authoritative route and write semantics.
## Diagnose and improve provider performance
[Section titled “Diagnose and improve provider performance”](#diagnose-and-improve-provider-performance)
Use these checks when the first cache fill is too slow, the route cannot be cached, or you need to understand a platform-specific difference.
### Establish a baseline
[Section titled “Establish a baseline”](#establish-a-baseline)
Benchmark the same command, profile, and set of secrets each time. `check` resolves values without printing them:
```bash
$ time secretspec get SECRET_NAME >/dev/null
$ time secretspec check --no-prompt
```
The redirected `get` prevents the value from appearing in the terminal. Compare it with the complete profile: similar times suggest a fixed authentication or connection cost, while time that grows with the number of secrets points to per-secret round trips, provider grouping, or concurrency.
If you have [hyperfine](https://github.com/sharkdp/hyperfine), compare repeated runs:
```bash
$ hyperfine --warmup 1 'secretspec check --no-prompt'
```
Record the first run separately. A warmup can hide cold authentication or an external CLI’s startup cost, while repeated one-shot SecretSpec commands still pay that cost in normal use.
### Isolate authentication and network cost
[Section titled “Isolate authentication and network cost”](#isolate-authentication-and-network-cost)
When a provider uses an external CLI, time a harmless authentication command without printing its token. For Azure CLI, for example:
```bash
$ time az account get-access-token \
--scope https://vault.azure.net/.default \
--output none
```
If this takes most of a cache miss, consider another supported authentication mode. External CLI sessions are convenient for local development, but direct service-principal, managed-identity, workload-identity, token, or SDK authentication can avoid the process boundary where appropriate.
For example, the [Azure Key Vault provider](/providers/akv/#authentication) supports Azure CLI sessions as well as service principals, managed identity, and workload identity. Prefer the identity mode that matches the environment; do not replace short-lived or workload-bound credentials with long-lived credentials solely to reduce latency.
Azure App Configuration (0.20+) supports the same Entra identity modes plus connection strings. When entries resolve Key Vault references, benchmark both the App Configuration request and the separate Key Vault request; a warm cache avoids both remote reads.
To distinguish connection setup from the secret API itself, probe the remote endpoint without requesting a real secret. This Azure Key Vault example is expected to return an authorization error, but still reports DNS, TCP, TLS, and time to first byte:
```bash
$ curl --silent --show-error --output /dev/null \
--write-out 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
'https://VAULT.vault.azure.net/secrets/__probe__?api-version=7.5'
```
Run the same probe from the host and from WSL or a container to expose a platform-specific DNS, VPN, proxy, or TLS delay.
### Keep related secrets on one route
[Section titled “Keep related secrets on one route”](#keep-related-secrets-on-one-route)
Secrets that use the same store and authentication configuration should select the same provider alias. SecretSpec groups those reads into one provider operation, deduplicates identical references, and lets providers use a bulk API or bounded parallel reads where supported.
Do not merge aliases that intentionally use different identities, endpoints, namespaces, or other security settings. Those are distinct routes even if they use the same provider type.
### Tune per-address concurrency
[Section titled “Tune per-address concurrency”](#tune-per-address-concurrency-017)
**New in version 0.17**
Providers using SecretSpec’s default per-address fetch path read up to eight unique addresses concurrently. Test a few caps when latency grows with the number of secrets:
```bash
$ SECRETSPEC_PROVIDER_CONCURRENCY=1 secretspec check --no-prompt
$ SECRETSPEC_PROVIDER_CONCURRENCY=4 secretspec check --no-prompt
$ SECRETSPEC_PROVIDER_CONCURRENCY=16 secretspec check --no-prompt
```
More concurrency is not always faster. It can increase rate limiting, overload a reverse proxy, or create more simultaneous connections. The setting does not remove a provider’s cold authentication floor, and providers with a true bulk API may not use it for their batched reads.
### Check WSL and container overhead
[Section titled “Check WSL and container overhead”](#check-wsl-and-container-overhead)
If the same manifest is slower under WSL or in a container:
* use the Linux build of each provider CLI instead of invoking a Windows executable through interoperability;
* keep the project and provider CLI’s configuration and credential cache on the Linux filesystem rather than under `/mnt/c`;
* compare the endpoint probe with and without `curl -4` to identify an address-family or VPN routing delay before changing system-wide networking;
* check whether a proxy, VPN, antivirus product, or certificate helper is only active on one side of the host boundary;
* remember that short-lived containers repeat process startup, authentication, DNS, and TLS setup on every invocation.
### Interpret the results
[Section titled “Interpret the results”](#interpret-the-results)
| Observation | Likely cost | First thing to try |
| -------------------------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------- |
| One secret and the full profile take about the same time | Authentication or process startup | Use direct authentication where appropriate, or cache the route |
| Every SecretSpec invocation has the same fixed delay | External CLI or cold connection setup | Time the auth command and endpoint probe separately |
| Time grows with the number of secrets | Per-secret network reads | Consolidate equivalent routes and benchmark concurrency |
| A warm run is much faster than the first | Provider CLI, token, DNS, or connection cache | Preserve the relevant cache and benchmark cold runs separately |
| Only WSL or a container is slower | Filesystem, DNS, VPN, proxy, or executable interoperability | Compare the host/container probes and move hot files off mounted host paths |
# Provider fallback
> Select providers and define ordered fallback routes for secrets
Secrets may live in different stores across environments or during a migration. An ordered provider route lets SecretSpec read from the first store that has a value while keeping one store as the write target.
## Provider selection order
[Section titled “Provider selection order”](#provider-selection-order)
SecretSpec selects each secret’s route in this order:
1. The `--provider` command-line option.
2. The `SECRETSPEC_PROVIDER` environment variable.
3. The secret’s effective `providers` list after profile inheritance and `[profiles..defaults]` are applied.
4. Project `[defaults].providers` (0.21+).
5. The default provider in the user configuration.
`--provider` and `SECRETSPEC_PROVIDER` replace the configured route for every secret. Without an override, effective `providers` list, or project default chain (0.21+), SecretSpec uses the user-level default.
## Ordered fallback routes
[Section titled “Ordered fallback routes”](#ordered-fallback-routes)
Provider lists accept aliases, provider names, and inline URIs. Reads try them from left to right:
secretspec.toml
```toml
[providers]
prod_vault = "onepassword://Production"
local = "keyring://"
[defaults] # 0.21+
providers = ["local"]
[profiles.production.defaults]
providers = ["prod_vault", "local"]
[profiles.production]
# Uses the profile default: prod_vault, then local.
DATABASE_URL = { description = "Production database" }
# Overrides the profile default and reads only from the environment.
DEPLOY_TOKEN = { description = "Deployment token", providers = ["env"] }
[profiles.development]
# Uses the project default provider chain in SecretSpec 0.21+.
DATABASE_URL = { description = "Development database" }
```
Reads stop at the first value. Writes and generated values go only to the first provider in the effective list (`prod_vault` above).
Later entries are resolved, constructed, and contacted only when needed. If a reached provider cannot be resolved, constructed, or read, SecretSpec warns and continues. If every reached provider fails, the operation returns a provider error rather than reporting the secret as absent.
Note
A secret’s [`ref`](/reference/configuration/#secret-references) changes only the address within a provider; route selection stays the same.
## Cached routes
[Section titled “Cached routes”](#cached-routes)
SecretSpec 0.17+ can place a local cache before an ordered route. See [Provider caching](/concepts/providers/caching/) for configuration and freshness rules.
## Next steps
[Section titled “Next steps”](#next-steps)
* Learn how to [configure provider aliases](/concepts/providers/#configure-provider-aliases).
* Learn how [Profiles](/concepts/profiles/) apply provider defaults.
# Secret References
> Point a secret at one already managed in a provider's store, by the store's own coordinates
**New in version 0.14**
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:
```toml
[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”](#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:
```plaintext
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.
```toml
# 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](/reference/configuration/#secret-references) documents exactly how each provider maps the coordinates.
## References name, providers route
[Section titled “References name, providers route”](#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](/concepts/providers/fallback/): a `--provider` override, then the secret’s `providers` chain, then profile defaults, project `[defaults].providers` in 0.21+, and the user-global default.
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:
```toml
[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.
```bash
$ secretspec run --provider dotenv:.env.fixtures -- cargo test
```
## Different coordinates per provider
[Section titled “Different coordinates per provider”](#different-coordinates-per-provider-019)
**New in version 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:
```toml
[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.` 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](/concepts/profiles/#switching-reference-models-019) for an example.
## How it works
[Section titled “How it works”](#how-it-works)
* `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.` 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](/concepts/audit/) events carry the coordinates.
See the [configuration reference](/reference/configuration/#secret-references) 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.
# Scopes
> Resolve and expose only the secrets a service or task needs
**New in version 0.17**
A profile defines how secrets behave in an environment. A scope selects which of those secrets one service, command, or task receives.
This lets several consumers share one profile without giving every consumer the complete secret set. An API and a background worker can use the same `production` profile, for example, while receiving different credentials.
## Define scopes
[Section titled “Define scopes”](#define-scopes)
Add a top-level `[scopes]` table to `secretspec.toml`. Each named scope contains an allowlist of secret names:
secretspec.toml
```toml
[profiles.default]
DATABASE_URL = { description = "Database" }
API_KEY = { description = "API key" }
QUEUE_TOKEN = { description = "Queue token" }
[scopes.api]
secrets = ["DATABASE_URL", "API_KEY"]
[scopes.worker]
secrets = ["DATABASE_URL", "QUEUE_TOKEN"]
```
The active profile still controls requirements, defaults, providers, references, generation, composition, and storage addresses. Selecting `api` only narrows the resolved set to `DATABASE_URL` and `API_KEY`; it does not create another profile or another copy of either secret.
A scope must contain at least one unique, non-blank name. Every name must be declared by at least one profile, although the active profile does not need to declare every member.
## Select a scope
[Section titled “Select a scope”](#select-a-scope)
`check`, `run`, and `export` accept `--scope`:
```bash
$ secretspec check --profile production --scope api
$ secretspec run --profile production --scope api -- ./api-server
$ secretspec export --profile production --scope worker --format dotenv
```
Set `SECRETSPEC_SCOPE` to select one without repeating the flag:
```bash
$ export SECRETSPEC_SCOPE=worker
$ secretspec run --profile production -- ./worker
```
The command-line flag takes precedence over the environment variable. With no scope selected, SecretSpec resolves the complete profile as before.
The untyped language SDK builders also accept an explicit scope and return its name in resolved and report results. See the [SDK overview](/sdk/overview/#the-runtime-api) for the shared behavior and each SDK guide for its language-specific method.
## What gets resolved
[Section titled “What gets resolved”](#what-gets-resolved)
The visible set is the intersection of the selected scope and the merged active profile:
* a required secret outside the scope does not block scoped resolution;
* a secret outside the scope is not fetched unless a visible composed secret needs it;
* a valid scope whose intersection with the profile is empty resolves nothing and contacts no provider;
* a scope member absent from the active profile is simply absent from the resolved result, allowing one scope to be reused across profiles with different shapes.
Scopes never change a secret’s `{project}/{profile}/{key}` storage address. A scoped and unscoped read of the same secret addresses the same provider value.
### Composed secrets
[Section titled “Composed secrets”](#composed-secrets)
A visible [composed secret](/concepts/composed-secrets/) may depend on values the scope excludes:
secretspec.toml
```toml
[profiles.default]
DB_USER = { description = "Database user" }
DB_PASSWORD = { description = "Database password" }
DB_HOST = { description = "Database host" }
DATABASE_URL = {
description = "Application database URL",
composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app"
}
[scopes.api]
secrets = ["DATABASE_URL"]
```
SecretSpec resolves the three inputs to build `DATABASE_URL`, then exposes only `DATABASE_URL`. A secret that is neither visible nor a dependency of a visible composition is never fetched.
## Narrow a process environment
[Section titled “Narrow a process environment”](#narrow-a-process-environment)
`run --scope` removes every manifest-declared secret the scope does not admit from the child environment, even when the parent shell had already exported it. The removal covers names declared in every profile, preventing a value inherited from another profile from leaking into the launched process.
`export --scope` only emits the selected values. It cannot remove variables that already exist in the current shell because its output formats cannot express an unset. Use `run --scope` when narrowing an existing environment is the goal.
Scopes minimize secret delivery; they are not an authorization boundary. A child that has access to `secretspec.toml` and valid provider credentials may resolve another scope itself. Use provider permissions, service isolation, and operating-system controls when the process must be prevented from reading other values.
## Validate scoped requirements
[Section titled “Validate scoped requirements”](#validate-scoped-requirements)
[Cross-secret presence constraints](/reference/configuration/#cross-secret-presence-constraints-017) are evaluated over the group members visible to the scope. A group with no visible members is not enforced for that consumer. When some members are visible, `at_least_one` or `exactly_one` is enforced over those members so a scoped consumer cannot rely on a credential it never receives.
Run an unscoped `secretspec check` when validating the complete profile rather than one consumer’s view.
## Profiles and inheritance
[Section titled “Profiles and inheritance”](#profiles-and-inheritance)
Scopes are orthogonal to profiles and reusable across them. They do not inherit from the `default` profile; instead, the selected scope is applied after normal profile inheritance produces the effective profile.
In SecretSpec 0.19+, a profile with `defaults.inherit = false` does not include any default-profile secrets. A scope still intersects the effective profile, so it cannot bring an inherited secret into a standalone profile.
When a project uses [`extends`](/concepts/inheritance/), a child scope replaces a parent scope with the same name. The lists are not unioned, so extending a configuration cannot silently widen an allowlist. Scopes defined only by a parent remain available to the child.
## Commands that ignore scopes
[Section titled “Commands that ignore scopes”](#commands-that-ignore-scopes)
`set` and `import` ignore `SECRETSPEC_SCOPE`: a resolution allowlist must not silently restrict which secrets a write or migration command manages.
Rust’s generated typed loaders also ignore the ambient scope because their structs contain a field for every secret in the profile. Use the untyped resolver API when a Rust consumer needs scoped resolution.
See the [`[scopes]` configuration reference](/reference/configuration/#scopes-section) for validation details, empty-selection behavior, audit semantics, and clearing an inherited scope selection.
# Adding a New Provider
> Step-by-step guide for implementing custom provider backends
## Provider Trait
[Section titled “Provider Trait”](#provider-trait)
All providers must implement the `Provider` trait. Every operation names its secret with an `Address`: either the store’s own coordinates (a secret’s `ref`) or SecretSpec’s `{project}/{profile}/{key}` naming convention, which your provider compiles into its native coordinates via `convention_address`:
```rust
pub trait Provider: Send + Sync {
fn name(&self) -> &'static str;
fn uri(&self) -> String;
/// Compile SecretSpec's naming convention into the store's native
/// coordinates. The single owner of the provider's convention layout.
fn convention_address(&self, project: &str, profile: &str, key: &str)
-> Result;
fn get(&self, addr: Address<'_>) -> Result