Declarative secrets manager
Declare secrets once.
Store them anywhere.
Stop leaking .env files. Define what your application needs in secretspec.toml, then plug in keyring, 1Password, Vault, AWS, Azure, Gopass, or any of 14 providers — same code, every environment.
[project]
name = "my-app"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "PostgreSQL", required = true }
REDIS_URL = { description = "Redis cache" }
TLS_CERT = { as_path = true }
DB_PASSWORD = { type = "password", generate = true }
[profiles.development]
# Inherits from default — override what changes
DATABASE_URL = { default = "postgresql://localhost/dev" } # 1. Initialize from existing .env
$ secretspec init --from dotenv
✓ Created secretspec.toml with 5 secrets
# 2. Pick a storage backend (one-time)
$ secretspec config init
? Select your preferred provider backend:
› keyring: Uses system keychain (Recommended)
infisical: Infisical secret management
✓ Saved to ~/.config/secretspec/config.toml
# 3. Run your app with secrets injected
$ secretspec run --profile production -- npm start
✓ Loaded DATABASE_URL from keyring
✓ Loaded REDIS_URL from keyring
✓ Generated DB_PASSWORD (32 chars)
✓ Wrote TLS_CERT to /tmp/secretspec-tls-cert
› npm start One declaration · Any provider
Three commands. Done.
From a leaky .env to a portable, declarative secrets setup the whole team can use.
Declare
Generate secretspec.toml from your existing .env, or write it by hand. Names and descriptions only — never values.
$ secretspec init --from dotenv Configure
Pick from 14 providers: system keyring, password managers, cloud secret stores, Gopass, dotenv, or environment variables.
$ secretspec config init
? Select your preferred provider backend:
> keyring: Uses system keychain (Recommended)
infisical: Infisical secret management Run
Secrets are loaded at runtime and injected as environment variables. Same command, every environment.
$ secretspec run -- npm start One file. Every secret. Every environment.
Built around the three questions every project answers: what secrets, how requirements differ per environment, and where values live.
Providers
Keyring · 1Password · LastPass · Bitwarden · Vault · AWS · GCP · Azure · Infisical · Gopass · Pass · Proton Pass · dotenv · env.
Profiles
Different requirements, defaults, and validation per environment. Optional locally, required in production — without touching app code.
[profiles.production]
DATABASE_URL = { required = true } Auto-generation
Passwords, tokens, and keys created automatically when missing — no manual setup, no copy paste.
DB_PASSWORD = { type = "password", generate = true } Composed secrets
Build read-only DSNs and arguments from independently stored secrets with strict, order-independent references.
DATABASE_URL = { composed = "postgres://{USER}:{PASSWORD}@{HOST}/app" } Per-secret fallback chains
Each secret can specify its own ordered provider list. Tries Vault first, falls back to keyring, then env — until the value is found.
API_KEY = { providers = ["vault", "keyring", "env"] } Type-safe Rust SDK
Proc macro reads secretspec.toml at compile time and generates strongly-typed structs. Typos fail to compile.
secretspec_derive::declare_secrets!("secretspec.toml");
let s = Secrets::builder().load()?;
println!("{}", s.secrets.database_url); Config inheritance
Extend shared configs across services with extends. Define once, reuse everywhere with proper precedence.
File-path secrets
Secrets with as_path = true get written to temp files — perfect for TLS certs and service account keys.
One-line migration
Move all secrets between providers without changing application code. secretspec import does the rest.
Export for shells and CI
Resolve a complete profile as shell exports, dotenv, JSON, or masked GitHub Actions environment variables.
$ secretspec export --format json Declare a secret. Swap the source.
Your secretspec.toml never changes. The same code reads from Keychain, 1Password, Vault, AWS, Azure, or any of 14 providers.
[project]
name = "my-app"
[profiles.default]
DATABASE_URL = { required = true }
STRIPE_SECRET_KEY = { required = true } DATABASE_URL $ secretspec run — injected as env var at runtime DATABASE_URL from env One config. Every environment.
Profiles let the same secret be optional in development, required in production — without changing application code. How profiles work →
Fallback chains, per secret.
Specify an ordered list of providers for each secret. SecretSpec walks the chain until it finds the value — perfect for shared team vaults with personal overrides.
[providers]
team_vault = "onepassword://Shared"
keyring = "keyring://"
env = "env://"
[profiles.production]
API_KEY = {
description = "Third-party API key",
providers = ["team_vault", "keyring", "env"]
} API_KEY in team_vault (1Password) Same code. Same secret. Different source per machine.
Keep the key to your vault in another vault.
Authenticate a provider with credentials stored in another provider. SecretSpec fetches them in memory, hands them directly to the destination store, and never exposes them to your application environment. How provider credentials work →
[providers]
keyring = "keyring://"
[providers.bws]
uri = "bws://project-uuid"
credentials = { access_token = "keyring" }
[profiles.production]
DATABASE_URL = {
description = "Production database",
providers = ["bws"]
} # Store the Bitwarden token in the OS keyring
$ secretspec config provider login bws
Enter access_token for provider 'bws': ****
✓ stored access_token in keyring
# SecretSpec authenticates BWS without exporting its token
$ secretspec run --provider bws -- ./deploy
✓ Loaded DATABASE_URL from bws
› ./deploy
Use convention paths for simple credentials, or a ref to source a specific 1Password field, Vault entry, or other provider-native address. Credential access is audited and cached for the invocation.
Name it once. Point it anywhere.
Already managing a secret in 1Password, Vault, or a .env file? Point at it by the store's own coordinates with ref. No renaming, no copying it into SecretSpec's own layout. How references work →
[profiles.production]
# Point at a secret that already lives in your store
DATABASE_URL = {
description = "Postgres DSN",
ref = { item = "db", field = "password" }
} ref names the store's own coordinates: item plus an optional field Point at secrets you already have. Switch stores without editing the ref.
Know who read a secret — and why.
Every access is appended to a local, append-only log — values never written, URI credentials redacted. And when an AI agent is driving, SecretSpec makes it state a reason first. How auditing works →
# An AI agent runs your app — no reason given
$ secretspec run -- ./deploy.sh
Error: accessing secrets requires a reason.
Provide one with --reason "<why...>"
# State why — required for agents by default
$ secretspec run --reason "Deploy web frontend" \
-- ./deploy.sh
✓ Loaded DATABASE_URL from keyring
› ./deploy.sh # Review who accessed what, why, and the outcome
$ secretspec audit -n 1
2026-06-04T17:04Z run started ./deploy.sh
DATABASE_URL (my-app/production via keyring://)
reason: Deploy web frontend [claude-code]
The default require_reason = "agents" policy is checked into secretspec.toml, so every clone, CI runner, and agent is held to it — humans running interactively are unaffected. Configure the policy →
Resolve once. Hand secrets to anything.
secretspec export resolves the complete active profile without launching a command. It never prompts and exits non-zero when a required secret is missing, making it a clean boundary for shells, scripts, and CI. Export command reference →
# Load a profile into the current shell
$ eval "$(secretspec export --profile production)"
# Or pass a JSON object to another tool
$ secretspec export --format json
{"DATABASE_URL":"postgresql://…",
"STRIPE_KEY":"sk_live_…"} # Make secrets available to later workflow steps
- name: Export secrets
run: secretspec export --format gha
- name: Deploy
run: ./deploy
# Values are added to $GITHUB_ENV and masked in logs
Choose shell, dotenv, json, or gha output without changing how secrets are declared or routed.
Use it from any language.
Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP, and C# all resolve from the same secretspec.toml through one Rust core — every provider, chain, and profile behaves identically, with no per-language logic. How the SDKs work →
from secretspec import SecretSpec
s = SecretSpec.builder() \
.with_provider("keyring://") \
.with_reason("boot").load()
print(s.secrets["DATABASE_URL"].get) const { SecretSpec } = require("secretspec");
const s = SecretSpec.builder()
.withProvider("keyring://")
.withReason("boot").load();
console.log(s.secrets.DATABASE_URL.get()); s, _ := secretspec.New().
WithProvider("keyring://").
WithReason("boot").Load()
fmt.Println(s.Secrets["DATABASE_URL"].Get()) s = Secretspec::SecretSpec.builder
.with_provider("keyring://")
.with_reason("boot").load
puts s.secrets["DATABASE_URL"].get s <- S.load (S.builder
& S.withProvider "keyring://"
& S.withReason "boot")
print (S.get =<< Map.lookup "DATABASE_URL" (S.resolvedSecrets s)) use Secretspec\SecretSpec;
$s = SecretSpec::builder()
->withProvider('keyring://')
->withReason('boot web app')->load();
$s->setAsEnv(); using Cachix.SecretSpec;
using var s = SecretSpec.Builder()
.WithProvider("keyring://")
.WithReason("boot").Load();
Console.WriteLine(s.Secrets["DATABASE_URL"].Get());
Every SDK is a thin client over one native core — the secretspec-ffi library, or a native extension that embeds it for Python, Node, and PHP — so a new provider is available everywhere at once. For typed access in the dynamic languages, secretspec schema feeds quicktype to generate typed classes. SDK overview →
Switch backends with one command.
Outgrowing dotenv? Standardizing on Vault? secretspec import moves every secret without touching a single line of application code.
DATABASE_URL=postgres://…
STRIPE_SECRET_KEY=sk_live_…
REDIS_URL=redis://… $ secretspec import dotenv://.env.production
✓ Imported 5 secrets to keyring://
✓ Application code unchanged Native devenv & Nix integration.
Enable SecretSpec in your devenv.yaml and every secret declared in secretspec.toml is loaded into config.secretspec.secrets — wire them into env vars, services, or processes from devenv.nix. devenv docs →
secretspec:
enable: true
provider: keyring # keyring, dotenv, env, 1password, …
profile: default { config, ... }:
{
# Wire any declared secret into the shell env
env.DATABASE_URL = config.secretspec.secrets.DATABASE_URL;
}
Switch providers per machine without touching devenv.nix: devenv --secretspec-provider dotenv --secretspec-profile dev shell.
Provider trait and ship a new backend. Stop leaking secrets.
Declare what your application needs. Store the values anywhere. Onboard new developers in one command.