Declare secrets once.
Store them anywhere.
Stop leaking .env files. Commit what your application expects in secretspec.toml, never the values. Developers, CI, and production can each resolve the same declaration from any of 14 providers.
[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
Set up SecretSpec in three steps.
Create a shared contract for the secrets your application needs, choose a provider for the values, and run.
Declare
Create secretspec.toml from an existing .env or by hand. Commit secret names and requirements, not their values.
$ secretspec init --from dotenv Provider
Choose where this machine looks for values: a system keyring, password manager, cloud secret store, or one of 14 providers.
$ secretspec config init
? Select your preferred provider backend:
> keyring: Uses system keychain (Recommended)
infisical: Infisical secret management Run
SecretSpec validates the active profile, resolves its values, and launches your application with them in the environment.
$ secretspec run -- npm start Make the declaration your source of truth.
The manifest describes what your application needs. Profiles describe when it is required. Providers decide where each value comes from.
Providers
Keep values in a local keyring, a team password manager, a cloud secret store, .env, or environment variables.
[defaults]
provider = "keyring" Profiles
Use convenient local defaults while requiring managed values in production, without changing application code.
[profiles.production]
DATABASE_URL = { required = true } Auto-generation
Create a password, token, or key automatically when a required value does not exist yet.
DB_PASSWORD = { type = "password", generate = true } Composed secrets
Store credentials separately, then assemble connection strings and other application-ready values during resolution.
DATABASE_URL = { composed = "postgres://${USER}:${PASSWORD}@${HOST}/app" } Per-secret fallback chains
Look in the team store first, then fall back to a personal keyring or environment variable when needed.
API_KEY = { providers = ["vault", "keyring", "env"] } Type-safe Rust SDK
Generate typed Rust fields from secretspec.toml so misspelled secret names fail at compile time.
secretspec_derive::declare_secrets!("secretspec.toml");
let s = Secrets::builder().load()?;
println!("{}", s.secrets.database_url); Config inheritance
Share a base declaration across services, then add or override only what each service needs.
extends = ["../shared/common"] File-path secrets
Materialize certificates and service account keys as temporary files for applications that expect a path.
TLS_CERT = { as_path = true } Provider migration
Copy values to a new provider without renaming variables or changing application code.
$ secretspec import dotenv://.env Export for shells and CI
Pass a resolved profile to shells, scripts, and CI as dotenv, JSON, shell exports, or GitHub Actions variables.
$ secretspec export --format json Keep secret names separate from storage.
Keep the declaration in your repository while each developer, CI job, or deployment chooses an appropriate provider. The application still receives the same variables.
[project]
name = "my-app"
[profiles.default]
DATABASE_URL = { required = true }
STRIPE_SECRET_KEY = { required = true } DATABASE_URL $ secretspec run adds it to the process environment DATABASE_URL as usual Change requirements by environment.
Start with shared requirements, then override only what changes. A secret can use a local default in development and a managed provider in production. How profiles work →
Give each secret its own fallback.
Give a secret an ordered provider list. SecretSpec can use the shared team vault when a value exists there, then fall back to a personal keyring or CI environment.
[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) One declaration, with a source that can vary by machine.
Keep provider credentials out of your app.
Store a provider token or machine credential in another provider, such as the OS keyring. SecretSpec uses it to connect to the destination store without adding it to your application's 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
Provider credentials are resolved separately from application secrets, so your application receives DATABASE_URL but not the token used to fetch it.
Use secrets where they already live.
Map an application variable to an existing item or field in 1Password, Vault, or another provider. You do not need to rename or copy the stored secret. 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 identifies the existing item and optional field Adopt SecretSpec without moving or renaming existing secrets.
See which secrets were accessed, by whom, and why.
SecretSpec records access metadata in a local append-only log without recording secret values. You can also require AI agents to provide a reason before they access secrets. How auditing works →
# An AI agent runs your app without a reason
$ 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]
Commit require_reason = "agents" with the manifest to apply the same policy in every clone while leaving interactive human use unchanged. Configure the policy →
Export secrets to shells, tools, and CI.
Export the active profile for a shell, another tool, or a CI workflow. Required secrets are still validated before any output is produced. 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 GitHub Actions output without maintaining a separate secret list for each tool.
Resolve secrets directly from your language.
All 8 SDKs—Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP, and C#—use the same resolver as the CLI, so provider and profile behavior stays consistent. Explore the SDKs →
secretspec_derive::declare_secrets!("secretspec.toml");
let s = SecretSpec::builder()
.with_provider("keyring://")
.with_reason("boot").load()?;
println!("{}", s.secrets.database_url); 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());
Choose the API for your language without reimplementing secret resolution. Generate typed models from secretspec schema when you want typed access. SDK overview →
Move secrets without changing your application.
Copy values from dotenv or another provider into the provider you use now. The declared names and the way your application reads them stay the same.
DATABASE_URL=postgres://…
STRIPE_SECRET_KEY=sk_live_…
REDIS_URL=redis://… $ secretspec import dotenv://.env.production
✓ Imported 5 secrets to keyring://
✓ Application code unchanged Use SecretSpec with devenv and Nix.
Enable the integration, choose a provider and profile, then use resolved values in your development shell, services, and processes. 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;
}
Each machine can select its own provider and profile without changing the shared devenv.nix.
Built in the open
SecretSpec is an Apache 2.0 project built by Cachix for devenv.sh, with development happening in public.
Provider trait and ship a new backend. Stop leaking .env files.
Start from your existing .env, commit the declaration, and let every environment choose where its values live.