SecretSpec
10 min read

SecretSpec is a declarative secrets manager built by Cachix (the people behind devenv). Its one idea: separate the declaration of a secret from its storage.

You commit a secretspec.toml that says which secrets the application needs — names, descriptions, requiredness — while the values themselves live in a provider: the system keyring, 1Password, Vault, SOPS, an age file, AWS Secrets Manager, and ~25 others. Nothing secret enters git, and no .env file has to be passed around.

It is Apache-2.0, written in Rust, and the tagline is "a declarative interface for every secret provider."

sh
curl -sSL https://install.secretspec.dev | sh
winget install --exact --id Cachix.SecretSpec
nix-env -iA secretspec -f https://github.com/NixOS/nixpkgs/tarball/nixpkgs-unstable

Binaries installed by the script self-update with secretspec-update.

The problem it targets

The README states it bluntly: secrets end up in .env files that get committed, shared over Slack, or copy-pasted between machines. Every developer has their own version, nobody knows which secrets are actually needed, and onboarding means asking around for values.

SecretSpec turns that implicit knowledge into a reviewable, version-controlled manifest. secretspec check then becomes a real answer to "do I have everything I need to run this?" — which direnv and plain dotenv files cannot give you, because they only carry values, never a contract.

The project positions itself as the application-facing layer of a secrets system, not a replacement for Vault or 1Password. It defines portable application semantics; each provider still decides how values are stored, protected and operated.

Quick start

shell-session
$ secretspec init # discovers secrets from an existing .env
✓ Created secretspec.toml with 0 secrets

$ secretspec config global init # pick a default provider + profile, interactively
? Select your preferred provider backend:
> keyring: Uses system keychain (Recommended)
onepassword: 1Password password manager
dotenv: Traditional .env files
...

$ secretspec check # verify everything required is set, prompt for what isn't
$ secretspec run -- npm start # inject as env vars into a child process

secretspec config global init --provider env --profile default does the same non-interactively.

secretspec.toml

toml
[project]
name = "my-app" # inferred from the directory by `secretspec init`
revision = "1.0" # format version, must be "1.0"
extends = ["../shared/common", "../shared/auth"]

[profiles.default]
DATABASE_URL = { description = "PostgreSQL connection string" }
REDIS_URL = { description = "Redis connection string" }

[profiles.development]
DATABASE_URL = { required = false, default = "sqlite://./dev.db" }
REDIS_URL = { required = false, default = "redis://localhost:6379" }

[profiles.production]
DATABASE_URL = { required = true }
REDIS_URL = { required = true }

The default profile is inherited by all others unless a profile opts out with [profiles.<name>.defaults] inherit = false (0.19+). A [profiles.<name>.defaults] table can also set profile-wide required, default and providers.

Per-secret attributes

AttributeMeaning
descriptionHuman-readable purpose — required on the effective secret
requiredBool, or a table for at_least_one / exactly_one groups (0.17+)
defaultValue used when the provider returns nothing
providersOrdered fallback chain of aliases/names/URIs for this one secret
typepassword, hex, base64, uuid, command, rsa_private_key
generateAuto-create when missing; needs type
promptAsk the operator for a hidden value during run (0.19+)
composedDerive a read-only value from other declared secrets (0.16+)
ref / refsPoint at an existing item in the remote store instead of SecretSpec's naming convention
as_pathMaterialise to a temp file, hand the app the path
encodingbase64, base64url or hex — how the value is stored (0.19+)
extractPull one field out of a JSON/INI blob via an RFC 6901 pointer (0.19+)

Profiles select environments; scopes (0.17+) cut across them, narrowing which secrets a given consumer sees:

toml
[scopes.api]
secrets = ["DATABASE_URL", "API_KEY"]

[scopes.worker]
secrets = ["DATABASE_URL", "QUEUE_TOKEN"]

sh
secretspec run --profile production --scope api -- ./api-server

check, run and export honour scopes; set and import deliberately ignore them so you cannot accidentally cripple a write. The docs are explicit that a scope is containment, not an authorization boundary — a process holding the config and the provider credentials can resolve the other scopes itself.

Providers

A provider can be named three ways: a bare name (keyring, vault), a URI (dotenv://.env.local, onepassword://Production), or an alias declared in [providers]. Project aliases beat user-level ones from ~/.config/secretspec/config.toml.

Roughly thirty backends ship in-tree:

• Local — keyring (recommended default), KeePass KDBX, plaintext file: trees, .env, environment variables (read-only), systemd credentials (read-only), null (defaults/generation only, no storage)
• Password managers — 1Password, Bitwarden PM (bw) and Bitwarden Secrets Manager (bws), LastPass, Dashlane (read-only), Keeper, Proton Pass, Passbolt, Pass, Gopass
• Cloud — AWS Secrets Manager, AWS Parameter Store, Google Cloud Secret Manager, Azure Key Vault, Azure App Configuration, Scaleway Secret Manager, Fly.io (write-only — Fly never hands plaintext back)
• Secret managers / encryption — HashiCorp Vault, OpenBao, Infisical, age, SOPS

Unless a ref overrides it, secrets are addressed by the convention secretspec/{project}/{profile}/{key} — a Vault KV path, a 1Password secure note, a pass entry, and so on. Azure Key Vault base32-encodes the components because of its charset limits; GCSM flattens to secretspec2--{project}--{profile}--{key}.

Swapping backend is a flag, never a code change:

sh
secretspec run --provider keyring -- npm start
secretspec run --provider "onepassword://Development" -- npm start
export SECRETSPEC_PROVIDER=env

Fallback chains, credentials and caching

Aliases can compose. A chain tries each route in order:

toml
[providers]
prod_vault = "onepassword://Production"
local = "keyring://"

[profiles.production]
DATABASE_URL = { description = "Production database", providers = ["prod_vault", "local"] }

Provider credentials are themselves resolved from a provider, so the 1Password token or Vault role ID never sits in a shell profile — and the application never sees it, only the secret it unlocks:

toml
[providers]
keyring = "keyring://"
bws = { uri = "bws://a9230ec4-...", credentials = { access_token = "keyring" } }

secretspec config provider login bws stores it. Caching (0.17+) puts a local store in front of a slow remote one:

toml
[providers]
local = "keyring://secretspec/cache/{project}/{profile}/{key}"
azure = { uri = "akv://team-vault", cache = { provider = "local", max_age = "8h" } }

Only providers that support deletion can back a cache (keyring, pass, gopass, dotenv, Vault/OpenBao KV v2, and 0.20+ age and Azure App Config). Entries are fingerprinted against the route, so changing the URI, ref or max_age invalidates them; secretspec cache clear [KEY] does it by hand. Cache failures warn but never block the authoritative read.

Generation and composition

Missing secrets can be created rather than asked for. Generation is idempotent — it fires only when the secret is absent, and never overwrites:

toml
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 } }
JWT_SIGNING_KEY = { description = "JWT key", type = "rsa_private_key", generate = { bits = 4096 } }
MONGO_KEY = { description = "Keyfile", type = "command", generate = { command = "openssl rand -base64 765" } }

Defaults are 32 alphanumeric chars for password, 32 bytes for hex/base64, 2048-bit PKCS1 PEM for rsa_private_key. Paired with the null provider this becomes ephemeral generation — a fresh value every resolution, stored nowhere.

Composed secrets (0.16+) build a read-only derived value out of other declared ones, which is how you avoid storing a whole DSN alongside its parts:

toml
DATABASE_URL = { composed = "postgres://${USER}:${PASSWORD}@${HOST}/app" }

References are order-independent and must be ${UPPERCASE_NAME}.

Audit logging

On by default since 0.17. Every get, set, check, run, import, export, delete and cache operation is recorded locally — metadata only, never values, with credentials in provider URIs redacted.

Records carry project, profile, secret names, provider, outcome (found, missing, default, written, deleted, started, error), timestamp, OS user and a supplied reason. The log is JSON Lines at ~/.local/state/secretspec/audit.log, mode 0600, capped at 1 MiB with truncate-and-restart instead of rotation.

sh
secretspec audit -n 20
secretspec audit --project my-app --action run
secretspec audit --json | jq 'select(.outcome == "missing")'

Notably, the actor field detects coding agents and sets an is_agent flag. require_reason defaults to "agents", meaning an agent-driven session must justify itself via --reason / SECRETSPEC_REASON / the SDK before it can read anything; require_reason = true demands it from every caller.

sh
secretspec run --reason "Deploy web frontend" -- ./deploy.sh

Auditing is configured only in the user-global config, never the project file — a repository cannot switch off its own logging:

toml
[audit]
enabled = false

CLI

Beyond init / check / run:

sh
secretspec add KEY --description "Purpose" # declare (0.18+)
secretspec set KEY / get KEY / delete KEY
secretspec export --format json|dotenv|gha
eval "$(secretspec export --profile production)"
secretspec import dotenv://.env.production # copy between providers
secretspec import onepassword:// --delete-source # move, after verifying (0.18+)
secretspec schema # JSON Schema of the manifest
secretspec completions fish | source

Global flags: -f/--file, --reason, and --caller / --caller-version / --caller-operation / --caller-resource for integrations identifying themselves to the audit log. Environment overrides: SECRETSPEC_PROFILE, SECRETSPEC_PROVIDER, SECRETSPEC_SCOPE, SECRETSPEC_FILE, SECRETSPEC_REASON.

SDKs

The Rust SDK generates typed structs from the manifest at compile time — optional secrets become Option<String>:

rust
secretspec_derive::declare_secrets!("secretspec.toml");

fn main() -> Result<(), Box<dyn std::error::Error>> {
let secrets = SecretSpec::load(Provider::Keyring)?;
println!("Database: {}", secrets.database_url);
if let Some(redis) = &secrets.redis_url {
println!("Redis: {}", redis);
}
Ok(())
}

Eight further SDKs are thin clients over the same native core, so providers, chains, profiles and generators behave identically with no per-language resolution logic: Python (pyo3), Node.js/TypeScript (napi-rs), Go (purego over the secretspec-ffi C ABI, no cgo), Ruby (C extension), PHP (ext-php-rs, ext-ffi fallback), C# (P/Invoke via NuGet, 0.16+), Swift (SwiftPM XCFramework, 0.18+), and Haskell.

python
from secretspec import SecretSpec

resolved = SecretSpec.builder().with_provider("keyring://").with_reason("boot").load()
print(resolved.secrets["DATABASE_URL"].get)

For typed access in a language without a first-class SDK, secretspec schema emits JSON Schema you can run through quicktype.

Integrations

devenv / Nix — the original driver for the project. Enable it in devenv.yaml and reference resolved secrets from devenv.nix, letting each machine pick its own provider without touching shared config:

yaml
# devenv.yaml
secretspec:
enable: true
provider: keyring
profile: default

nix
# devenv.nix
{ config, ... }: {
env.DATABASE_URL = config.secretspec.secrets.DATABASE_URL;
}

GitHub Actions — cachix/secretspec-action installs the CLI and runs secretspec export --format gha, which masks every value in the runner log, appends them to $GITHUB_ENV, and fails the step up front if a required secret is missing. Works identically under Forgejo Actions.

yaml
- uses: cachix/secretspec-action@main
with:
profile: production
provider: vault://vault.example.com:8200/secret?auth=jwt&role=ci
permissions:
id-token: write

With Vault or OpenBao the runner's OIDC token is exchanged for a short-lived client token, so no long-lived credential is stored on the CI platform at all. Other CI systems just call secretspec run -- or secretspec export directly.

Git credential helper (0.20+) — authenticates HTTPS clone/fetch/push and git send-email SMTP through any provider, instead of duplicating tokens into a second credential store. The URL is matched before the provider is loaded, so a token for https://github.com is never returned for another host or for plain HTTP.

sh
secretspec git configure --url https://github.com --username YOUR_USERNAME --global
secretspec git login https://github.com

Status and caveats

The project started in July 2025 and is pre-1.0 — the latest crates.io release is 0.19.1 (August 2026), with 0.20 features (Fly.io, Azure App Configuration, the git helper, static musl binaries for Alpine) documented ahead of the tag. Version gates are dense enough that the docs annotate nearly every feature with the release that introduced it; check them before relying on something.

Worth knowing:

• Dynamic secrets and credential rotation are not supported. The docs list them as roadmap items with no target release. If you need Vault-style short-lived dynamic credentials, SecretSpec is the wrong layer.
• Secrets still reach the child process as environment variables in the normal run path, so process-inspection exposure is reduced, not eliminated — as_path and scopes narrow it.
• Providers are added through a Provider trait, so an unlisted backend is a contribution rather than a fork.

See also

• devenv — same authors; the integration SecretSpec was extracted for
• SOPS and sops-nix — encrypt secrets into git; SecretSpec keeps them out of it entirely, and can use SOPS as a backend
• age — usable as a SecretSpec provider
• direnv — loads values per-directory, but declares nothing