Skip to content

Blog

Secrets Don’t Belong in Config

Applications should not require passwords, API keys, or tokens in their configuration files.

Configuration describes behavior. It belongs in git, code review, bug reports, and developer machines.

A secret grants authority. It needs restricted access and independent rotation.

Putting both in one file couples different lifecycles and audiences. If rotating a password requires regenerating application configuration, the interface has coupled them too tightly.

We audited all 445 NixOS modules that handle a real secret in nixpkgs at commit 141f212, classifying each by where its secret value ends up.

Where the secret value ends upModulesShare
Merged into a config file at runtime11025%
Inlined into a config in /nix/store429%
Delivered as an environment variable16136%
Left in a dedicated file opened by the app5813%
Loaded through systemd credentials5312%
Passed as a command-line argument194%
Classification uncertain2

The interesting number is 110. A quarter of the modules retrieve a secret safely, then copy it into configuration because that is the only interface the application accepts.

These modules use envsubst, replace-secret, jq, yq, sed, or custom code to assemble a restricted file at startup. The result can be secure, but every module now owns application-specific, security-sensitive glue just to combine two inputs that should have remained separate.

This is not unique to NixOS. The same workaround appears as an entrypoint script, Helm template, init container, or CI interpolation step on other platforms.

As a side note, 42 modules can inline secrets into the world-readable /nix/store. That direct security problem is tracked in nixpkgs issue #24288. The 110 runtime mergers make the broader point: even when deployment authors avoid the leak, the missing separation still creates work.

Applications should accept secret values through a dedicated runtime channel, such as:

  • a password_file or token_file setting;
  • a systemd credential;
  • a narrowly scoped environment variable;
  • or an external secret provider.

These mechanisms are not equally safe: environment variables can be inherited, arguments can appear in process listings, and files still need correct permissions. What separation does guarantee is that the deployer no longer has to manufacture a second, secret-bearing version of the configuration.

The principle is simple; implementing it across environments is not. Local development might use a system keyring, CI environment variables, and production 1Password or Vault. Without a shared abstraction, each environment needs its own naming, lookup, validation, and injection glue.

Cachix historically stored its auth token and per-cache signing keys in ~/.config/cachix/cachix.dhall, alongside cache names and other configuration. It was convenient, but the file had to be treated as a secret even though much of it was ordinary configuration.

A typical file mixed them directly:

~/.config/cachix/cachix.dhall
{ authToken = "XXX-AUTH-TOKEN"
, binaryCaches =
[ { name = "mycache"
, secretKey = "XXX-SIGNING-KEY"
}
]
}

The cache name is configuration; the auth token and signing key are secrets. You could not share the cache configuration without also sharing credentials.

devenv 2.2 separates the token through SecretSpec. The project declares CACHIX_AUTH_TOKEN, devenv resolves it from the configured provider, and the value is passed to Cachix without being added to devenv’s configuration.

Cachix PR #737 brings the same boundary into the client through the SecretSpec Haskell SDK. It resolves CACHIX_AUTH_TOKEN and CACHIX_SIGNING_KEY from SecretSpec and can store them in the user’s chosen provider instead of cachix.dhall. Existing environment variables and config files remain higher-priority fallbacks for compatibility. The PR is still open and is not available in a released Cachix version yet.

That is the problem SecretSpec is designed to solve: configuration declares the requirement, while each environment chooses where the value lives.

SecretSpec applies that separation by making secretspec.toml a declaration of what an application needs, without storing the values:

secretspec.toml
[project]
name = "myapp"
[profiles.production]
DATABASE_URL = { description = "Postgres connection string" }
STRIPE_API_KEY = { description = "Stripe secret key" }

Providers decide where the values live. A developer can use the system keyring, CI can use environment variables, and production can use 1Password, Vault/OpenBao, or a cloud secret manager without changing the declaration.

An existing application can receive the resolved values at startup:

Terminal window
secretspec run -- ./myapp

Applications can also resolve them directly through the SecretSpec SDKs for Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP, and C#, all sharing the same resolver so behavior stays consistent across languages.

Providers own where secret values come from. SDKs give applications an idiomatic way to consume them. Configuration remains a shareable declaration of what is required.

If you maintain an application, stop adding passwords and tokens to ordinary configuration schemas. Accept a file reference, credential, environment variable, or provider instead.

For NixOS, SecretSpec issue #65 tracks how an official integration could declare and resolve secrets without per-module substitution glue.

Consistent secret handling across developer machines, CI, and production used to require infrastructure that only dedicated platform teams could build. A project of any size should be able to separate secrets from configuration without building its own secrets platform first.

SecretSpec 0.16: Composed secrets, Infisical, and C# SDK

SecretSpec 0.16 ships:

  • Composed secrets — derive a read-only value, such as a connection string, from other secrets declared in the manifest.
  • Infisical — read and write secrets in Infisical Cloud or a self-hosted instance, with Universal Auth, access-token, and provider-credential authentication.
  • C# SDK — resolve the same manifests from .NET through the shared native resolver, distributed as the Cachix.SecretSpec NuGet package.

Applications often need a connection string while secret stores work better with its independently rotated parts. SecretSpec can now keep those parts separate and assemble the application-facing value when it resolves the manifest:

secretspec.toml
[profiles.default]
DB_USER = { description = "Database user" }
DB_PASSWORD = { description = "Database password" }
DB_HOST = { description = "Database host" }
DATABASE_URL = {
description = "PostgreSQL connection string",
composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app"
}

DB_USER, DB_PASSWORD, and DB_HOST still come from their configured providers. DATABASE_URL is assembled in memory and behaves like any other resolved secret in the CLI and SDKs. Compositions are read-only, may build on other compositions, and are checked for missing references and cycles before resolution.

See Composed Secrets for optional values, escaping, profile inheritance, and validation rules.

The new infisical:// provider works with Infisical Cloud, its EU service, and self-hosted instances. Point SecretSpec at an Infisical project and authenticate with Universal Auth:

Terminal window
export INFISICAL_CLIENT_ID=...
export INFISICAL_CLIENT_SECRET=...
secretspec run \
--provider "infisical://app.infisical.com/7e2f1a4c-...?env=prod" \
-- npm start

Access tokens are also supported. Credentials can come from environment variables or SecretSpec’s provider credentials, allowing, for example, an Infisical machine identity to be kept in the system keyring:

secretspec.toml
[providers.infisical]
uri = "infisical://app.infisical.com/7e2f1a4c-..."
[providers.infisical.credentials]
client_id = "keyring"
client_secret = "keyring"

By default, the active SecretSpec profile also names the Infisical environment. A production profile therefore reads from the production environment, while ?env= can select a different one. The provider supports normal SecretSpec reads and writes, as well as references to existing Infisical secrets and versions.

See the Infisical provider guide for self-hosting, authentication, paths, references, and permissions.

The Cachix.SecretSpec NuGet package brings the shared SecretSpec resolver to .NET 8:

Terminal window
dotnet add package Cachix.SecretSpec
using Cachix.SecretSpec;
using var resolved = SecretSpec.Builder()
.WithProvider("keyring://")
.WithProfile("production")
.WithReason("boot web app")
.Load();
Console.WriteLine(resolved.Secrets["DATABASE_URL"].Get());
resolved.SetAsEnv();

It uses the same resolver as the CLI and other language SDKs, so profiles, providers, fallback chains, references, generators, audit reasons, and composed secrets work consistently in .NET. Native resolver builds are included in the NuGet package, with no separate SecretSpec CLI installation required.

See the C# SDK guide for supported platforms, ASP.NET Core integration, preflight reports, error handling, and typed access.

Terminal window
cargo install secretspec

All three additions are opt-in: existing manifests and provider configurations continue to work unchanged. Add composed when a value should be derived, select an infisical:// provider to use Infisical, or install Cachix.SecretSpec in a .NET application.

See the full changelog for every change in this release.

Questions or feedback? Join us on Discord.

SecretSpec 0.15: Provider credentials, Azure Key Vault / Gopass, and PHP SDK

SecretSpec 0.15 ships:

  • Provider credentials — authenticate one secret provider with credentials stored in another, without exporting them to the application environment.
  • Azure Key Vault — store and resolve secrets with service-principal, Azure CLI, managed-identity, or AKS workload-identity authentication.
  • Gopass — use a GPG-encrypted, git-synchronized password store, including multi-user and multi-store setups.
  • PHP SDK — use the shared SecretSpec resolver from PHP-FPM, Laravel, Symfony, and CLI applications through a native extension or ext-ffi.
  • AWS creation guardrails — set a customer-managed KMS key and required tags when SecretSpec creates an AWS Secrets Manager secret.
  • secretspec export — resolve secrets without launching a command, with shell, dotenv, JSON, and GitHub Actions output.
  • Provider and resolution fixes — ordered lazy fallback chains, early ref validation, correctly merged profile overrides, stable output, and broader Node.js Linux compatibility.

Suppose Bitwarden Secrets Manager holds an application’s secrets, but its machine access token is kept in the user’s OS keyring. Declare the relationship on the provider alias:

secretspec.toml
[providers]
keyring = "keyring://"
[providers.bws]
uri = "bws://a9230ec4-5507-4870-b8b5-b3f500587e4c"
[providers.bws.credentials]
access_token = "keyring"

Before SecretSpec connects to Bitwarden, it reads access_token from the keyring at the normal {project}/{profile}/access_token address. The active profile is part of that address, so production and development can authenticate as different machines without changing the alias.

When a credential already has a provider-native address, use a ref. Here a Vault AppRole is kept as two fields of one 1Password item:

secretspec.toml
[providers.vault_prod]
uri = "vault://secret/myapp?auth=approle"
[providers.vault_prod.credentials]
role_id.provider = "onepassword"
role_id.ref.vault = "Infra"
role_id.ref.item = "vault-approle"
role_id.ref.field = "role_id"
secret_id.provider = "onepassword"
secret_id.ref.vault = "Infra"
secret_id.ref.item = "vault-approle"
secret_id.ref.field = "secret_id"

The credential source uses the same ref coordinates as application secrets. The difference is where the value goes: SecretSpec hands it directly to the destination provider in memory. It is not added to the environment of a process started by secretspec run.

Provider credential names are semantic and checked before a source is opened. Bitwarden accepts access_token; Vault accepts token, role_id, and secret_id; 1Password accepts service_account_token; Azure Key Vault accepts tenant_id, client_id, and client_secret. A configured credential is authoritative, while a provider’s usual environment fallback remains available when no credential source is declared.

Credential chains deliberately stop after one hop. The store containing a provider credential cannot itself depend on another provider credential. This keeps the bootstrap path finite and makes dependency mistakes fail before any store is contacted.

Log in once, without an environment variable

Section titled “Log in once, without an environment variable”

The new config provider login command prompts for every credential an alias declares and writes it to the configured source:

Terminal window
$ secretspec config provider login bws
Enter access_token for provider 'bws' (source: keyring): ****
✓ stored access_token in keyring at my-app/default/access_token

A user-level alias and its credential source can also be declared entirely with config provider add:

Terminal window
secretspec config provider add bws "bws://project-uuid" \
--credential access_token=keyring
secretspec config provider login bws

Credentials are fetched once per invocation and profile, then reused for every secret routed through that alias. Each credential read, and each value stored by login, gets an audit event marked with the semantic credential name and source store. As with every SecretSpec audit event, the credential value is never recorded.

See Provider Credentials for the full configuration and resolution rules.

Azure Key Vault joins the provider list with the akv:// scheme:

Terminal window
# Use service-principal credentials, or the current Azure CLI session
secretspec run --provider akv://myvault -- npm start
# Use the platform's managed identity
secretspec check --provider akv://myvault?auth=managed_identity
# Use AKS workload identity federation
secretspec run --provider akv://myvault?auth=workload_identity -- ./deploy

The default authentication mode first looks for the tenant_id, client_id, and client_secret provider credentials introduced above, then their AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET environment fallbacks. If none are present, it uses the signed-in Azure CLI or Azure Developer CLI session. A partial service principal is an error, rather than a reason to silently switch identities.

That makes a service principal straightforward to keep in the system keyring:

secretspec.toml
[providers.azure]
uri = "akv://myvault"
[providers.azure.credentials]
tenant_id = "keyring"
client_id = "keyring"
client_secret = "keyring"
Terminal window
secretspec config provider login azure
secretspec run --provider azure -- ./deploy

Sovereign clouds can use either a complete vault hostname or an explicit DNS suffix such as akv://myvault?suffix=vault.azure.cn.

Azure restricts secret names to letters, digits, and hyphens and compares them case-insensitively. SecretSpec encodes the project, profile, and key as lowercase, unpadded Base32 components. The encoding keeps names that differ by case or punctuation distinct instead of letting Azure collapse them onto the same secret. Existing Azure secrets can be addressed with a read-only ref.

See the Azure Key Vault provider guide for authentication, naming, references, and required permissions.

The new gopass:// provider reads and writes through the gopass CLI. Gopass builds on the Unix pass provider with multi-user and multi-store support while keeping entries GPG-encrypted and synchronized through git.

Once Gopass is installed and its password store is initialized, select it like any other provider:

Terminal window
secretspec set DATABASE_URL --provider gopass
secretspec run --provider gopass -- npm start

By default, entries live under secretspec/{project}/{profile}/{key}. A custom URI can change that layout, including omitting {project} to share secrets between repositories:

~/.config/secretspec/config.toml
[defaults.providers]
shared = "gopass://secretspec/shared/{profile}/{key}"

An existing Gopass entry can also be addressed directly with a ref, including the mount-point prefix used by a multi-store setup. See the Gopass provider guide for installation, shared-store configuration, references, and current limitations.

The new cachix/secretspec Composer package brings the shared resolver to PHP:

Terminal window
composer require cachix/secretspec
<?php
use Secretspec\SecretSpec;
$resolved = SecretSpec::builder()
->withProfile('production')
->withReason('boot web app')
->load();
echo $resolved->secrets['DATABASE_URL']->get();
$resolved->setAsEnv();

It offers two native backends behind the same PHP API. The recommended native extension embeds the resolver and works under PHP-FPM without ffi.enable, like ext-redis. An ext-ffi fallback loads the shared resolver at runtime for CLI tools and local development. Both use the same Rust core as the CLI and the other language SDKs, so profiles, providers, fallback chains, generators, as_path, audit reasons, and typed missing-secret errors behave the same way.

setAsEnv() updates getenv(), $_ENV, and $_SERVER, which lets Laravel’s env() helper and Symfony’s %env(...)% processors consume resolved secrets during application boot. See the PHP SDK guide for installation and framework examples.

AWS accounts often require a customer-managed KMS key or specific tags in the same CreateSecret request. The AWS Secrets Manager provider now accepts both on its URI:

secretspec.toml
[providers]
prod = "awssm://prod@us-east-1?kms_key_id=alias/my-key&tag.team=platform&tag.env=prod"

kms_key_id and repeatable tag.NAME=VALUE parameters are applied only when SecretSpec creates a secret. Updating an existing secret does not alter the key or tags it was created with. This supports tag-on-create SCP and IAM guardrails without turning routine secret updates into infrastructure changes.

The new export command resolves every secret for the active profile without starting another process. Its default output can be evaluated by a POSIX shell:

Terminal window
eval "$(secretspec export --profile production)"

Use --format dotenv to write dotenv syntax or --format json to pass the resolved values to another tool:

Terminal window
$ secretspec export --profile production --format json
{
"DATABASE_URL": "postgresql://prod.example.com/mydb"
}

GitHub and Forgejo Actions can use --format gha. SecretSpec masks every value in the runner log and appends it to $GITHUB_ENV, making the secrets available to later steps and third-party actions:

- name: Export secrets
run: secretspec export --profile production --format gha
- name: Deploy
run: ./deploy

Like non-interactive check, export never prompts and exits non-zero when a required secret is missing, so it can gate a CI job. Export attempts are also recorded in the audit log. See the export CLI reference for every format and option.

0.15 also tightens the behavior around profiles and fallback chains:

  • Provider chains are now walked strictly in order and resolved lazily. An undefined alias or unreachable fallback is skipped with a warning only when a read reaches it, so a later working provider can still answer.
  • Chain entries accept aliases, bare provider names such as keyring, shorthand such as dotenv:.env, and complete provider URIs.
  • A single destination provider rejects unsupported ref coordinates before contacting the store. Multi-provider chains still validate each destination as they reach it, because an earlier store may support coordinates a later one does not.
  • Profile overrides inherit the base secret’s description and generation type. Validation now uses the effective merged secret while still catching real conflicts, such as combining generate with a profile default.
  • run passes non-UTF-8 environment variables through to the child untouched, and command output that previously depended on map order is now stable.
  • Prebuilt Node.js addons now target glibc 2.28 and statically include libdbus, restoring support for Amazon Linux 2023, RHEL 8/9, and similar distributions.
Terminal window
cargo install secretspec

Existing providers retain their conventional environment authentication when an alias does not declare credentials. Provider credentials are opt-in, and credential dependency chains are limited to one hop.

See the full changelog for every change and fix in this release.

Questions or feedback? Join us on Discord.

SecretSpec 0.14: Secret references

SecretSpec keeps a secretspec.toml that declares what secrets an application needs, and resolves the values from a provider: your system keyring, 1Password, Vault, a .env file, and so on. Until now it stored every secret under a naming convention it controlled, secretspec/{project}/{profile}/{key}, and that convention was the only place it looked.

That works when SecretSpec created the secret. It does not when the secret already exists under a name something else chose: a db item in a 1Password vault, a myapp/config path at a Vault mount, an environment variable your platform already sets. To manage such a secret you had to copy its value into SecretSpec’s convention, leaving two copies to rotate, or leave it out of SecretSpec entirely.

SecretSpec 0.14 introduces ref. A secret can name one that already exists, by the store’s own coordinates, and SecretSpec reads and writes that secret in place:

[profiles.production]
DATABASE_URL = { description = "Postgres DSN", ref = { item = "db", field = "password" }, providers = ["prod_op"] }

DATABASE_URL now resolves from the password field of the 1Password item db. SecretSpec does not prepend a project or profile, and does not create a name of its own.

1Password will give you an address for that field: op://Production/db/password. The obvious design is to accept that string in the config and be done. We built that first and removed it.

A string like op://Production/db/password names the store and the secret at the same time, which ties the secret to 1Password. The same reference cannot then resolve from Vault in CI and 1Password on a laptop, cannot be redirected at a .env fixture for a test run without editing the manifest, and does not compose with a provider fallback chain, because the chain and the address disagree about where the secret is.

SecretSpec already decides which store to use, through providers, profiles, fallback chains, and the --provider override. A ref names only the secret and leaves the store to that existing machinery.

A ref is a table, not a URL. Each key names a level of structure that some stores have:

vault which container holds the item (1Password only)
└── item the store's own name for the secret (always required)
└── section a named group of fields (1Password only)
└── field one component inside the item (structured stores)
└── version which revision to read (GCSM only)

Only item is required, because every store names its secrets somehow. item is the complete name: it replaces the whole convention path, with no project or profile and no folder prefix prepended. ref = { item = "GITHUB_PAT" } on the env provider reads the environment variable GITHUB_PAT and nothing else.

The other keys refine item for stores that have that structure. A .env key holds a single value, so field on a dotenv ref is not meaningful. A Vault KV entry is a map, so field is required. When a store has no equivalent for a coordinate it reports an error naming that coordinate, rather than reading a different secret:

GITHUB_TOKEN = { description = "GitHub token", ref = { item = "GITHUB_PAT", field = "x" }, providers = ["env"] }
Error: Provider operation failed: the env provider does not support the `field` coordinate. Drop `field` from the ref for `GITHUB_PAT`.

All eleven providers resolve refs, and each rejects the coordinates it cannot represent. A store whose secrets have no internal parts gets that rejection from shared code, without any per-provider work.

A ref supplies the name only. Which provider resolves it follows the normal provider resolution order: a --provider override, then the secret’s providers chain, then profile and global defaults. That is the same order every other secret uses.

Because the store is not part of the reference, the same ref works across providers. Each provider in a fallback chain is asked for the same coordinates, and one that cannot interpret them logs a warning and the chain continues:

[profiles.production]
DATABASE_URL = { description = "Postgres DSN", ref = { item = "db", field = "password" }, providers = ["onepassword://Production", "keyring"] }

Chain entries can also be inline scheme:// URIs, as above, with no [providers] alias declared first.

The --provider override redirects a referenced secret the same way it redirects a conventional one, so pointing a whole suite at a .env fixture needs no change to the manifest:

Terminal window
$ secretspec run --provider dotenv:.env.fixtures -- cargo test

Reads and writes use the same coordinates. secretspec set and interactive check write to the referenced secret in place wherever the store supports writes:

Terminal window
$ secretspec set DATABASE_URL
# writes the `password` field of the 1Password item `db`, in place

1Password edits the field with op item edit and does not create items. Keyring, pass, dotenv, Bitwarden, Proton Pass, and LastPass write their refs as well. Vault, AWS Secrets Manager, and Google Secret Manager are read-only for refs and report that directly, rather than claiming the provider cannot write at all.

A ref also composes with generate. If the referenced secret does not exist yet, SecretSpec generates the value and writes it to the coordinates, so the first check populates the item everything else already reads.

check, run, and the SDKs now group secrets by store and fetch the groups concurrently instead of one store after another. Within a group, referenced secrets use the store’s bulk API where it has one (AWS BatchGetSecretValue, and the single Bitwarden, Proton Pass, and 1Password listings) and otherwise resolve concurrently, fetching each unique coordinate once. CLI authentication for 1Password, LastPass, and Proton Pass is probed once per account or session instead of once per provider instance.

Terminal window
cargo install secretspec

Three changes to be aware of:

  • A onepassword:// URI carrying an item path used to drop the path and target a vault literally named vault. Item paths, including pasted op://vault/item/field strings, now fail with an error that gives the ref table to write instead. Provider URIs are store addresses only.
  • ref is always a table. String and URI forms are rejected, with the same translation in the error.
  • Manifest validation now runs on every load. Rules that secretspec.toml documents (a required secret cannot have a default, generate needs a type, ref coordinates must be non-empty) are enforced on load rather than ignored. A manifest that violated one of them will now fail with a clear error.

See Secret References for the full model and the configuration reference for how each provider maps the coordinates. Questions or feedback? Join us on Discord.

SecretSpec 0.13: SDKs for Python, Node.js, Go, Ruby, and Haskell

SecretSpec separates what secrets an application needs, declared in secretspec.toml, from where the values live, a provider like your system keyring, 1Password, or Vault. Until now, reading those resolved secrets at runtime meant the CLI or the Rust SDK. If your service was written in Python or Go, you shelled out to secretspec run or reimplemented resolution yourself.

SecretSpec 0.13 closes that gap. It ships native SDKs for five languages: Python, Node.js / TypeScript, Go, Ruby, and Haskell. Each resolves the exact secrets your manifest declares, through the same providers, profiles, fallback chains, and generators as the CLI, with no per-language configuration.

Every SDK is a thin client over the same Rust core that powers the CLI. No provider logic, profile resolution, chain fallback, as_path materialization, or secret generation lives in the binding. A provider added to SecretSpec works in every language the day it lands, and every SDK behaves identically.

The binding strategy is chosen per ecosystem:

  • Python: a pyo3 extension, statically linked, shipped as a self-contained cp39-abi3 wheel.
  • Node.js: a napi-rs addon with prebuilt per-platform packages.
  • Ruby: a native C extension (mkmf) with the resolver statically linked into a platform gem.
  • Go: the secretspec-ffi C ABI loaded at runtime via purego (no cgo).
  • Haskell: the same C ABI, linked at build time through the Haskell FFI.

Each SDK mirrors the vocabulary of the Rust derive crate: a builder that takes a provider, a profile, and an access reason, then load() to resolve, then a map of secrets you can read or export into the environment.

# Python
from secretspec import SecretSpec
resolved = (
SecretSpec.builder()
.with_provider("keyring://")
.with_profile("production")
.with_reason("boot web app")
.load()
)
print(resolved.secrets["DATABASE_URL"].get) # value, or file path for as_path
resolved.set_as_env() # export into os.environ
// Node.js / TypeScript
const { SecretSpec } = require('secretspec');
const resolved = SecretSpec.builder()
.withProvider('keyring://')
.withProfile('production')
.withReason('boot web app')
.load();
console.log(resolved.secrets.DATABASE_URL.get()); // value, or as_path file path
resolved.setAsEnv(); // export into process.env
// Go
resolved, err := secretspec.New().
WithProvider("keyring://").
WithProfile("production").
WithReason("boot web app").
Load()
fmt.Println(resolved.Secrets["DATABASE_URL"].Get()) // value, or as_path file path
resolved.SetAsEnv() // export into the environment
# Ruby
resolved = Secretspec::SecretSpec.builder
.with_provider("keyring://")
.with_profile("production")
.with_reason("boot web app")
.load
puts resolved.secrets["DATABASE_URL"].get # value, or as_path file path
resolved.set_as_env! # export into ENV
-- Haskell
resolved <-
S.load
( S.builder
& S.withProvider "keyring://"
& S.withProfile "production"
& S.withReason "boot web app"
)
S.setAsEnv resolved -- export into the environment

Across all of them, load() resolves every declared secret, a missing required secret raises a typed MissingRequiredError, and as_path secrets come back as a readable file path with a cleanup that removes the backing temp file. The access reason feeds the same audit log and require_reason policy from 0.12, so a Go service is as accountable as the CLI.

Under all five SDKs sits a new crate, secretspec-ffi: a small, versioned C ABI for resolving secrets. If we do not ship your language yet, you can bind to it directly. It also exposes the public Rust building blocks the SDKs share, Secrets::resolve() and Secrets::report(), so a Rust program reaches the same value-carrying and value-free entry points.

Typed secrets, one schema for every language

Section titled “Typed secrets, one schema for every language”

secretspec.toml already knows the shape of your secrets, so 0.13 can hand that shape to your type system. secretspec schema emits a JSON Schema for your manifest, the union of all profiles or one profile with --profile. Pipe it through quicktype to generate idiomatic typed classes in any language, then populate them from each SDK’s fields() map:

Terminal window
secretspec schema | quicktype -s schema --top-level SecretSpec --lang python -o secrets_gen.py
typed = Secrets.from_dict(resolved.fields())
print(typed.database_url) # typed str

One schema drives every language’s type system, with no hand-written emitter per language.

Terminal window
pip install secretspec # Python
npm install secretspec # Node.js / TypeScript
gem install secretspec # Ruby
go get github.com/cachix/secretspec/secretspec-go # Go

For Haskell, add secretspec from Hackage to your build-depends. The CLI and Rust SDK upgrade as usual:

Terminal window
cargo install secretspec

See the SDK overview for the per-language guides. Questions or feedback? Join us on Discord.