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

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 20 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 user-global default (0.17+)
$ secretspec config global init
? Select your preferred provider backend:
 keyring: Uses system keychain (Recommended)
  kdbx: KeePass KDBX databases (0.17+)
  infisical: Infisical secret management
  openbao: OpenBao secret management (0.17+)
  age: age-encrypted file (0.17+)
  sops: SOPS encrypted files (0.17+)
  systemd-credential: Read-only systemd service credentials (0.17+)
  scaleway: Scaleway Secret Manager (0.17+)
 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 20 providers.

$ secretspec config global init # 0.17+
? Select your preferred provider backend:
> keyring: Uses system keychain (Recommended)
  kdbx: KeePass KDBX databases (0.17+)
  infisical: Infisical secret management
  openbao: OpenBao secret management (0.17+)
  age: age-encrypted file (0.17+)
  sops: SOPS encrypted files (0.17+)
  systemd-credential: Read-only systemd service credentials (0.17+)
  scaleway: Scaleway Secret Manager (0.17+)
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. Scopes (0.17+) decide which consumer receives it.

20

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 }

Scopes (0.17+)

Resolve and expose only the declared subset of secrets that each service, command, or task needs.

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

Provider caching (0.17+)

Reuse a fresh local copy of a slow provider route for a configured window, with explicit invalidation.

fast_vault = {
  fallback = ["vault"],
  cache = { provider = "local", max_age = "8h" }
}

Credential alternatives (0.17+)

Require at least one credential—or exactly one—without treating every alternative as independently required.

PASSWORD = {
  required = { at_least_one = "auth" }
}
ACCESS_TOKEN = {
  required = { at_least_one = "auth" }
}

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
Scopes · 0.17+

Give each service only the secrets it needs.

Keep one profile for an environment, then select an allowlist for each service, command, or task. Secrets outside the selected scope are not exposed to the child process. How scopes work →

secretspec.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"]
terminal
# The API receives its database and API credentials
$ secretspec run --profile production --scope api -- ./api
 Loaded DATABASE_URL
 Loaded API_KEY
 ./api

# The worker receives its database and queue credentials
$ secretspec run --profile production --scope worker -- ./worker
 Loaded DATABASE_URL
 Loaded QUEUE_TOKEN
 ./worker

Scopes minimize secret delivery; they are not an authorization boundary when the child process can access provider credentials.

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 require sessions SecretSpec detects as AI agents—or every caller—to provide a reason before SecretSpec proceeds. How auditing works →

detected agent session
# A detected 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 detected 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" to require reasons in sessions SecretSpec detects as agents. Detection is heuristic; use require_reason = true to require every SecretSpec caller to supply one. Configure the policy →

Export & Actions

Send secrets to shells, tools, and CI.

Export the active profile for a shell or another tool, or resolve it in GitHub and Forgejo Actions with one workflow step. Required secrets are 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 & Forgejo Actions (0.17+)
# Resolve only the API deployment scope
- uses: actions/checkout@v7
- uses: cachix/secretspec-action@main
  with:
    profile: production
    scope: api
- run: ./deploy.sh

# Later steps receive masked, validated secrets

The 0.17+ action masks every value, fails when a required secret is missing, and exposes the selected scope to later steps. Vault and OpenBao can use the runner's OIDC identity instead of a stored provider credential. Configure Actions →

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.