Skip to content
Latest from the blog Jul 18, 2026 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.

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.

secretspec.toml
[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" }
terminal
# 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.

1

Declare

Create secretspec.toml from an existing .env or by hand. Commit secret names and requirements, not their values.

$ secretspec init --from dotenv
2

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
3

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.

14

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
Declaration vs storage

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.

secretspec.toml
[project]
name = "my-app"

[profiles.default]
DATABASE_URL      = { required = true }
STRIPE_SECRET_KEY = { required = true }
secretspec.toml says the app requires DATABASE_URL
SecretSpec resolves it from System keyring
$ secretspec run adds it to the process environment
Your app reads DATABASE_URL as usual
Profiles

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 →

[profiles.default]
DATABASE_URL = { required = true }
[profiles.development]
DATABASE_URL = { default = "postgresql://localhost/dev" }
[profiles.production]
DATABASE_URL = { providers = ["vault", "keyring"] }
Same command. Every profile.
$ secretspec run --profile production -- npm start
--profile development --profile staging --profile production SECRETSPEC_PROFILE=ci
Per-secret providers

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.

secretspec.toml
[providers]
team_vault = "onepassword://Shared"
keyring    = "keyring://"
env        = "env://"

[profiles.production]
API_KEY = {
  description = "Third-party API key",
  providers = ["team_vault", "keyring", "env"]
}
Look up API_KEY in team_vault (1Password)
Found in keyring

One declaration, with a source that can vary by machine.

Provider credentials

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 →

secretspec.toml
[providers]
keyring = "keyring://"

[providers.bws]
uri = "bws://project-uuid"
credentials = { access_token = "keyring" }

[profiles.production]
DATABASE_URL = {
  description = "Production database",
  providers = ["bws"]
}
terminal
# 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.

Secret references

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 →

secretspec.toml
[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
Your application receives it as DATABASE_URL

Adopt SecretSpec without moving or renaming existing secrets.

Audit & AI agents

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 →

agent session
# 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
secretspec audit
# 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

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 →

shell and JSON
# 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_…"}
GitHub Actions
# 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.

Language SDKs

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 →

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

let s = SecretSpec::builder()
    .with_provider("keyring://")
    .with_reason("boot").load()?;
println!("{}", s.secrets.database_url);
Python
from secretspec import SecretSpec

s = SecretSpec.builder() \
    .with_provider("keyring://") \
    .with_reason("boot").load()
print(s.secrets["DATABASE_URL"].get)
Node.js
const { SecretSpec } = require("secretspec");

const s = SecretSpec.builder()
  .withProvider("keyring://")
  .withReason("boot").load();
console.log(s.secrets.DATABASE_URL.get());
Go
s, _ := secretspec.New().
    WithProvider("keyring://").
    WithReason("boot").Load()
fmt.Println(s.Secrets["DATABASE_URL"].Get())
Ruby
s = Secretspec::SecretSpec.builder
      .with_provider("keyring://")
      .with_reason("boot").load
puts s.secrets["DATABASE_URL"].get
Haskell
s <- S.load (S.builder
      & S.withProvider "keyring://"
      & S.withReason "boot")
print (S.get =<< Map.lookup "DATABASE_URL" (S.resolvedSecrets s))
PHP · Laravel · Symfony
use Secretspec\SecretSpec;

$s = SecretSpec::builder()
    ->withProvider('keyring://')
    ->withReason('boot web app')->load();
$s->setAsEnv();
C# / .NET
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 →

Migration

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.

1 · From
dotenv://.env.production
DATABASE_URL=postgres://…
STRIPE_SECRET_KEY=sk_live_…
REDIS_URL=redis://…
2 · To
keyring://
$ secretspec import dotenv://.env.production
✓ Imported 5 secrets to keyring://
✓ Application code unchanged
devenv & Nix

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 →

devenv.yaml
secretspec:
  enable: true
  provider: keyring   # keyring, dotenv, env, 1password, …
  profile: default
devenv.nix
{ 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.

Stop leaking .env files.

Start from your existing .env, commit the declaration, and let every environment choose where its values live.