Skip to content

Blog

SecretSpec 0.21: Resolver IPC, external providers, and binary secrets

SecretSpec 0.21 adds two JSON-RPC protocols: one lets tools request secrets from a separate resolver process; the other lets external executables act as providers.

This release includes:

Work on Nix support drove this protocol. Nix needs forge tokens, netrc files, and build secrets at use time, without putting values in its configuration. The Nix integration is separate upstream work; 0.21 provides the protocol it can use.

With secretspec serve, a tool can request one declared secret over a private stdio JSON-RPC connection. The resolver applies the manifest and provider routing, resolving the named secret and its dependencies without requiring unrelated secrets.

Results can be inline values or leased temporary files that the resolver cleans up when the session closes. Clients can answer prompts, and credential helpers can use the same session for login and logout. Read-only mode disables provider writes.

Applications can use JSON-RPC directly or a packaged client. The protocol also works over authenticated streams, including SSH. Existing SDKs still offer embedded resolution.

The Secret Provider Protocol lets an external executable connect an internal vault, approval service, or other secret store to SecretSpec. SecretSpec launches the provider executable named in its trusted configuration.

FactorSeal is a desktop and CLI secrets vault for Linux, macOS, and Windows, being developed as a first-class SecretSpec integration through the provider protocol.

External providers declare credentials at runtime. login can collect them:

Terminal window
$ secretspec config provider login company_vault

External providers can also request missing credentials during writes and return approval references that link their requests to the local audit log. See the implementation guide for protocol and registration details.

SecretSpec now preserves arbitrary bytes through providers, fallback chains, imports, and caches. Use as_path for applications that need a file:

secretspec.toml
[profiles.default]
CLIENT_KEYSTORE = { description = "Client TLS keystore", as_path = true }
Terminal window
$ secretspec set CLIENT_KEYSTORE --from-file client.p12
$ secretspec run -- ./my-application

CLIENT_KEYSTORE becomes a temporary path containing the original bytes. --from-file - preserves exact stdin bytes, including trailing newlines. Byte-capable providers store values natively; use encoding = "base64" with text-only providers.

Rust callers can use resolve_bytes() or resolve_named_bytes(). Text SDK responses and exports still require UTF-8 and error on non-UTF-8 values.

In SecretSpec 0.21+, set [defaults].providers in secretspec.toml to give secrets stored by providers in every profile one default chain:

secretspec.toml
[defaults]
providers = ["developer"]
[profiles.default]
DATABASE_URL = { description = "Development database URL" }
API_TOKEN = { description = "Development API token" }

Each developer can map developer to their own backend in the user config:

~/.config/secretspec/config.toml
[defaults.providers]
developer = "keyring://"

The project [defaults].providers selects a chain; the user [defaults.providers] table defines aliases. A secret’s own providers chain takes precedence, followed by its profile defaults, then the project default. See the provider guide for an example with personal provider coordinates.

The Claude Code integration supplies API and gateway credentials from any SecretSpec provider through Claude Code’s native apiKeyHelper:

Terminal window
$ secretspec claude configure
$ secretspec claude login
$ claude

configure adds a managed helper to the repository’s personal .claude/settings.local.json; login stores the credential. Settings retain only a machine-local configuration identifier. The helper works in worktrees and with --global; SecretSpec preserves unrelated settings.

This covers API and gateway authentication. Claude Code keeps subscription OAuth credentials. Usage is billed to the account behind the active credential. See the integration guide for authentication precedence and custom manifests.

The Doppler provider reads, writes, and deletes secrets through Doppler’s REST API without the Doppler CLI. It keeps names unchanged, so values also work with doppler run and the dashboard.

secretspec.toml
[providers]
production = "doppler://myapp/prd"
[profiles.production]
DATABASE_URL = { description = "Production database", providers = ["production"] }

Authenticate with DOPPLER_TOKEN or a token provider credential. Without a config in the URI, the SecretSpec profile selects one. Projects and configs must already exist. init --from discovers names without reading values.

The Setec provider uses the caller’s Tailscale identity and Setec grants. It needs no separate API token:

Terminal window
$ secretspec set DATABASE_URL --provider setec://secrets.example.ts.net
$ secretspec run --provider setec://secrets.example.ts.net -- npm start

Setec supports reads, writes, deletion, discovery, binary values, and pinned version reads.

Generate a recovery passphrase on first use:

secretspec.toml
[profiles.default.RECOVERY_CODE]
description = "Operator recovery code"
type = "passphrase"
generate = true

SecretSpec stores it through the configured provider, so later runs reuse it. 0.21 adds eight generation types:

TypeUse case
passphraseHuman-readable recovery codes, with configurable word count and separator
mnemonicChecksum-valid BIP-39 recovery mnemonics
openpgp_private_keyOpenPGP signing and encryption
ssh_private_keySSH authentication
wireguard_private_keyWireGuard tunnel credentials
jwk_private_keyPrivate signing keys in JWK format, including public parameters
age_identityNative X25519 identities for age encryption
x509_identityA private key and self-signed certificate stored together as a PKCS#12 archive

Generated private keys are not passphrase-protected, so store them in an encrypted provider. See secret generation for the options and defaults.

One TLS identity, several application formats

Section titled “One TLS identity, several application formats”

One x509_identity can supply a PKCS#12 archive or separate certificate and key files:

secretspec.toml
[profiles.default.TLS_IDENTITY]
description = "Local development TLS identity"
type = "x509_identity"
generate = { san = ["dns:localhost", "ip:127.0.0.1"] }
[profiles.default.TLS_CERT]
description = "TLS certificate"
type = "x509_certificate"
from = "TLS_IDENTITY"
as_path = true
[profiles.default.TLS_KEY]
description = "TLS private key"
type = "pkcs8_private_key"
from = "TLS_IDENTITY"
as_path = true

Derived declarations are read-only; scopes can expose their files without exposing the source identity. See the credential generation work for formats and conversion options.

JSON Schemas for project and user configuration give editors autocomplete, hover descriptions, and validation. Export schemas matching your CLI:

Terminal window
$ secretspec schema --config project --output secretspec.schema.json
$ secretspec schema --config global --output config.schema.json

See editor autocomplete to associate them with your TOML files.

  • Bitwarden Password Manager accepts exact item UUIDs and batches reads from one vault listing.
  • KeePassXC KDBX 4.0 databases can be written; writes upgrade them to 4.1 while preserving encryption and key-derivation settings.
  • Cache planning avoids redundant reads and rejects a cache that points to the same physical secret as its authoritative provider.
  • pass, gopass, and LastPass preserve whitespace and multiline values. LastPass rejects NUL bytes before writing.

Once 0.21 is released:

Terminal window
$ cargo install secretspec --version 0.21.0

The new integrations and providers are opt-in. Check these behavior changes:

  • secretspec get adds no newline when writing to a pipe or file. Terminal output still gets one.
  • Command generators preserve stdout bytes, including trailing newlines. Trim them in the generator if needed. Empty or whitespace-only output is rejected.
  • Piped secretspec set input remains trimmed text; use --from-file - for exact bytes.
  • Existing gopass text entries return their trimmed first line until rewritten. New multiline, whitespace-padded, and binary entries use a lossless format.
  • On macOS, an unsigned build may need one Always Allow prompt per SecretSpec keyring item after an upgrade. The keyring upgrade fix then transfers ownership to the new build so later reads stay silent. Choosing Allow can leave repeated prompts.
  • Rust provider and consumer code must handle byte-valued secrets and convert to text where required.

Building libsecretspec-resolver requires system yyjson. Static linking also requires yyjson; the installed pkg-config metadata records that dependency.

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

Questions or feedback? Join us on Discord.

Claude Code Stores OAuth Tokens in Plaintext

Claude Code’s MCP documentation says authentication tokens are “stored securely”. On Linux, that currently means plaintext JSON protected by file permissions.

I checked Claude Code 2.1.257 after authenticating to several remote MCP servers. The file ~/.claude/.credentials.json had mode 0600, as it should, but it also contained a top-level mcpOAuth object with the access tokens. Here is the shape of one Cloudflare entry, with every credential value redacted:

~/.claude/.credentials.json
{
"mcpOAuth": {
"cloudflare-observability|…": {
"accessToken": "<redacted>",
"clientId": "<redacted>",
"discoveryState": "<redacted>",
"redirectUri": "<redacted>",
"serverName": "cloudflare-observability",
"serverUrl": "<redacted>"
}
}
}

This matches Anthropic’s credential-management documentation.

  • macOS: uses the encrypted macOS Keychain, falling back to ~/.claude/.credentials.json when the Keychain is unavailable.
  • Linux: uses ~/.claude/.credentials.json with mode 0600.
  • Windows: uses %USERPROFILE%\.claude\.credentials.json, inheriting the access controls of the user’s profile directory.

That is a much narrower claim than most people hear when a product says a credential is “stored securely.”

The browser flow makes the secret easy to miss. Run claude mcp login, approve access in the browser, and return to a connected MCP server. Nobody manually created a token, copied it from a dashboard, or pasted it into a configuration file.

But Claude Code still received a credential. It must persist that credential if the connection is to survive a restart.

OAuth is valuable here. Claude Code can discover the authorization server, request specific scopes, complete the authorization-code exchange, refresh an access token, and revoke the grant. Anthropic’s MCP documentation also lets users pin the scopes Claude Code requests.

What OAuth does not specify is a secure local vault.

API tokens can also be scoped, limited to particular resources, assigned an expiry, rotated, and revoked independently. OAuth standardizes delegation and renewal, while avoiding the copy-and-paste ceremony. Those are substantial benefits, but they do not turn the resulting bearer token into something that is safe to leave in plaintext.

PropertyOAuth credentialScoped API token
Must be stored by the clientYesYes
Can have limited permissionsYesYes
Can expireYesYes
Can be revoked independentlyUsuallyYes
Can be replayed if stolenYesYes
Standard interactive delegationYesProvider-specific
Standard automatic renewalOftenUsually external

OAuth solves how Claude Code obtains and renews a delegated credential. Secret storage solves what happens to that credential between uses. They are separate concerns.

Claude Code needs a credential-store interface

Section titled “Claude Code needs a credential-store interface”

Claude Code should not decide that every Linux user’s MCP tokens belong in the same plaintext file. The persistence layer should be replaceable:

Claude Code OAuth client
│
▼
credential-store interface
│
▼
SecretSpec TypeScript SDK
│
▼
user-selected SecretSpec provider

The right integration point is the SecretSpec Node.js / TypeScript SDK. It embeds the Rust resolver, so the TypeScript side does not need bespoke code for each backend. Claude Code could serialize one MCP OAuth credential per server and ask SecretSpec to load, save, or delete it using the provider the user or organization selected.

SecretSpec 0.20 has 33 provider integrations. They cover local keyrings, password managers, encrypted files, cloud secret managers, and deployment destinations. Providers declare their capabilities, so a credential store can require readable and writable storage while still using the same interface everywhere.

The current TypeScript SDK exposes SecretSpec’s provider-independent resolver. We would add the small get/set/delete credential-store surface Claude Code needs rather than reimplement 33 integrations in its codebase.

For example, set the system keyring as the default provider in the local user configuration:

Terminal window
$ secretspec config global init --provider keyring --profile default

A team might instead require OpenBao or a cloud secret manager. A headless workstation might use an age-encrypted store. The OAuth flow would stay exactly the same; only persistence would change.

What Codex does: Codex makes MCP OAuth storage configurable. Its configuration reference documents auto, file, and keyring backends. Setting mcp_oauth_credentials_store = "keyring" selects the system keyring. This is not a general secret-provider interface, but it avoids making a plaintext credential file the only option on Linux.

Coming in SecretSpec 0.21: We are working on versioned resolver and provider IPC for zero-dependency integrations. Applications will be able to use SecretSpec providers over a local protocol without embedding an SDK or provider code.

We are working to make open source tools retrieve credentials from a user-selected secret store instead of copying them into another plaintext file. SecretSpec now provides Git and Docker credential helpers, and we have proposed a generic, operation-scoped secret resolver interface for Nix.

Because these projects are open source, we can inspect their credential boundaries and contribute safer ones upstream.

We cannot make the equivalent fix in Claude Code. Its public repository does not include the core CLI implementation, and its license is all rights reserved. We can support the open request for secure, pluggable credential storage and propose a SecretSpec TypeScript integration, but only Anthropic can change Claude Code’s MCP OAuth storage today.

That is one of the practical security benefits of open source: when a secret crosses the wrong boundary, users do not have to wait for the vendor to decide that the boundary matters.

SecretSpec 0.20: Git, Docker, inline specs, and five new providers

SecretSpec 0.20 brings SecretSpec-managed credentials to Git and Docker, lets applications declare secrets directly in code, adds five providers, and expands support for JVM and Alpine applications.

This release includes:

Git and Docker can now read credentials directly from any SecretSpec provider. Configure each helper once, then keep using the usual Git and Docker commands.

The Git integration registers a helper for a host, then stores its token through your normal SecretSpec provider:

Terminal window
$ secretspec git configure \
--url https://github.com \
--username YOUR_USERNAME
$ secretspec git login https://github.com

After that, normal commands invoke git-credential-secretspec automatically:

Terminal window
$ git clone https://github.com/OWNER/PRIVATE_REPOSITORY.git
$ git push

The helper works before a repository has been cloned and can scope credentials by host or URL path. It also supports SMTP credentials for git send-email, so the password does not need to live in sendemail.smtpPass.

The Docker integration uses one helper per registry. Configure the registry and store its token separately:

Terminal window
$ secretspec docker configure \
--registry ghcr.io \
--username YOUR_USERNAME
$ secretspec docker login ghcr.io

docker pull, docker push, docker build, and Docker Compose can now retrieve the value through docker-credential-secretspec:

Terminal window
$ docker pull ghcr.io/OWNER/IMAGE:TAG

Docker’s config.json contains only the helper configuration, not the credential value. Separate DOCKER_CONFIG directories can use different credentials for the same registry.

The helpers only read credentials. Use secretspec git login / logout and secretspec docker login / logout to change stored values. Use configure and unconfigure to add or remove the helpers themselves.

secretspec.toml remains the portable contract shared by the CLI and every SDK. Applications can now define that contract directly when keeping a separate manifest is inconvenient.

Rust applications can use the new public Spec, Profile, and Secret types to build a specification in code:

use secretspec::{Profile, Secret, Secrets, Spec};
let spec = Spec::builder("checkout")
.provider("env", "env://")
.secret(
"DATABASE_URL",
Secret::required("PostgreSQL connection URL").providers(["env"]),
)
.profile(
"production",
Profile::new().secret(
"SENTRY_DSN",
Secret::required("Production Sentry endpoint"),
),
)
.build()?;
let mut secrets = Secrets::from_spec(spec)?;
secrets.set_profile("production");
let resolved = secrets.resolve()?;

Rust-built specs use the same validation as secretspec.toml. Spec::schema_json generates the same JSON Schema as secretspec schema, and SpecBuilder can update a file-backed spec without losing comments or formatting.

See Describing secrets in Rust for the complete builder and format-preserving editing API.

Typed Rust loaders generated by declare_secrets! can now call prompt_missing() to ask for and store missing required values. Prompting remains opt-in.

SecretSpec 0.20 gives the SDKs the same new capabilities across languages. It also renames secretspec-ffi to libsecretspec. Packaged SDKs handle the rename automatically; update your build only if you link or load libsecretspec directly.

The SDKs for Go, Python, Node.js, Ruby, Haskell, PHP, C#, and Swift can now accept an inline specification. This is useful when the application already owns its configuration or cannot rely on a manifest on disk. For example, in Go:

spec := map[string]any{
"project": map[string]any{"name": "checkout"},
"profiles": map[string]any{"default": map[string]any{
"secrets": map[string]any{
"API_TOKEN": map[string]any{"description": "API token"},
},
}},
}
resolved, err := secretspec.New().
WithInlineSpec(spec, "/logical/project").
Load()

The base directory tells SecretSpec where to resolve relative provider paths and inherited manifests. Invalid fields and unsupported spec versions fail with an error.

Git and Docker set caller context automatically, recording which tool and operation requested a secret. CLI and SDK callers can provide the same context:

Terminal window
$ secretspec get GITHUB_TOKEN \
--caller git \
--caller-version 2.51.0 \
--caller-operation credential_get \
--caller-resource github.com \
--reason "push release tag"

Audit records include the caller name, version, operation, and resource. This is separate from require_reason, so policies can record both which tool accessed a secret and why.

The new JVM SDK lets Java, Kotlin, and other JVM applications load SecretSpec secrets directly. Add it with Gradle:

dependencies {
implementation("org.cachix:secretspec-jvm:0.20.0")
}

Then load secrets with the same builder pattern as the other SDKs:

import org.cachix.secretspec.SecretSpec;
try (var resolved = SecretSpec.builder()
.withProvider("keyring://")
.withProfile("production")
.withReason("boot web app")
.load()) {
System.out.println(resolved.secret("DATABASE_URL").get());
resolved.setAsSystemProperties();
}

The package supports JDK 11 or newer on Linux, macOS, and Windows, including x64 and Arm64 systems. It includes everything needed to resolve secrets, so the application does not need a separate SecretSpec CLI or Rust toolchain.

The standalone installer and secretspec-update now work on Alpine Linux. The Node.js SDK also works directly in images such as node:alpine, without a glibc compatibility layer.

SecretSpec 0.20 adds five providers, bringing the total to 33.

Use Azure App Configuration to read and manage ordinary key-values or follow references to Azure Key Vault secrets.

Terminal window
$ az login
$ secretspec set DATABASE_URL --provider aac://payments-production
$ secretspec run --provider aac://payments-production -- ./payments

It works with service principals, Azure CLI sessions, managed identity, workload identity, and connection strings. Provider URLs can select labels, prefixes, and tags.

Kubernetes stores values in a ConfigMap or Secret using the current kubeconfig context:

Terminal window
$ secretspec set DATABASE_URL \
--provider k8s+secret://app-credentials@production
$ secretspec run \
--provider k8s+secret://app-credentials@production \
-- ./application

You can use convention keys or point declarations at existing .data entries. The provider supports reads, writes, deletion, and declaration discovery.

Shopify’s EJSON lets teams keep encrypted secrets alongside application source. Secret values are encrypted with a public key and decrypted with the matching private key, while the JSON structure stays visible.

SecretSpec’s EJSON provider reads string values from those files. A ref.item selects any RFC 6901 JSON Pointer, while convention addresses use /{project}/{profile}/{key}.

Supply the EJSON private key as a SecretSpec provider credential. It can come from Google Cloud Secret Manager, the system keyring, or any other readable provider. The EJSON provider is read-only.

Use Fly.io to publish and delete application secrets with flyctl. Secret values stay out of process arguments, and app-scoped deploy tokens can come from provider credentials. Fly.io does not return plaintext, so keep the original value in a readable provider and use fly as the deployment destination.

Use Cloudflare Secrets Store to publish and delete account-level secrets through Cloudflare’s API. Authenticate with a scoped API token from another provider or an existing Wrangler session. Cloudflare does not return plaintext, so keep the original value in a readable provider and use cloudflare as the deployment destination.

Shell completions now come directly from the CLI definition:

Terminal window
$ source <(secretspec completions bash)

Bash, Elvish, Fish, Nushell, PowerShell, and Zsh now complete commands, profiles, scopes, secret names, providers, files, and executables. Completion does not read secret values. See secretspec completions for persistent installation instructions (#330).

0.20 also includes these CLI and automation changes:

  • On Unix, secretspec run forwards SIGTERM, SIGINT, and SIGHUP to its child, so containers can shut down gracefully (#391).
  • check --json, check --explain, and SDK reports now mark an unprovisioned required generate declaration as missing_required instead of resolved. Run secretspec check or secretspec run once to generate and store it (#394).
  • Human-readable secretspec check output moves to stdout, matching its JSON and explain modes. Diagnostics remain on stderr (#372).
  • Dotenv files, age-encrypted dotenv blobs, and export --format dotenv now use dotenv-ng. Dollar signs and bcrypt-style values now round-trip correctly (#73).
  • extract supports INI documents, selecting unsectioned keys with /key and named-section keys with /section/key (#386).
  • The age provider can now delete secrets, enabling secretspec delete, import --delete-source, and age-backed provider caches (#328).
  • Closing a stdout pipe is quiet on Unix, so commands such as secretspec export | head behave like other Unix tools (#377).

Provider-specific fixes keep 1Password batches fast when optional items are missing (#401), prevent Bitwarden convention names from colliding across projects and profiles (#390), make Infisical Universal Auth sessions more reliable (#402), and let Node.js applications exit cleanly after AWS resolution (#365).

Terminal window
$ cargo install secretspec

For most users, 0.20 is a drop-in upgrade. The new integrations, providers, and inline declarations are opt-in. Check these cases before upgrading:

  • scripts that captured human-readable secretspec check output from stderr should capture stdout;
  • Google Cloud Secret Manager writes now use the collision-safe secretspec2--{project}--{profile}--{key} convention. Reads fall back to the matching 0.19 name until a write moves the value, leaving the old secret in place for rollback;
  • Bitwarden Password Manager convention items now use secretspec/{project}/{profile}/{key} titles. init --from bw:// preserves references to legacy bare items so they can keep working or be migrated deliberately.

Rust applications using secretspec-derive can remove direct serde or secrecy dependencies if they were needed only for generated types. Code using the old raw configuration or code-generation APIs should move to Spec, SpecBuilder, Profile, and Secret.

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

The resolver and provider IPC protocols are currently RFCs in PR #362. The resolver protocol would let applications request individual secrets from secretspec serve. The provider protocol would let external executables act as SecretSpec providers. Both are targeted for 0.21 and may change during review.

Work on the Nix integration identified that SecretSpec first needs the IPC layer above, so the concrete integration is blocked until 0.21 is released. In the meantime, the underlying refactoring has been split into a generic secret-provider interface for Nix.

Separately, devenv’s experimental machines interface aims to handle NixOS installation, NixOS and nix-darwin deployment, and home-manager activation in one workflow. Its SecretSpec integration can supply deployment credentials without putting their values in Nix evaluation or store paths. This work follows devenv’s own release schedule and is not part of SecretSpec 0.21.

Questions or feedback? Join us on Discord.

In partnership with SecretSpec, Fencer has expanded its offering to open source. Public repositories now get free static analysis, dependency scanning, secret scanning, and GitHub configuration scanning, with no credit card and no expiry.

We recommend Fencer for code security scanning, and Fencer recommends SecretSpec for secrets management. Fencer finds credentials and other risks already in a repository, while SecretSpec helps keep the next secret in the right provider instead of source code or a dotenv file.

We Are Forking dotenvy into dotenv-ng

We have released dotenv-ng 1.0, a modern Rust implementation for loading and rendering .env files. It began as a fork of dotenvy after its parser changed a secret while reading it.

That may sound contradictory. SecretSpec is still on a mission to eliminate environment variables as a secrets interface, and we have written about where .env went wrong. It should not be the final home of a secret.

But migrating away from .env starts with reading it correctly.

The immediate failure was SecretSpec issue #73. A dotenv file contained a value with bcrypt fragments:

TEST="foo:$2a$10$TWoviNHS27HJMw1PKe4tBeIMlms6tWdYS9hKoHANKCQhluDlEt/gu"

The file was intact. Reading it through the dotenv provider returned a different value because dotenvy treated the dollar-prefixed fragments as variable substitutions. The failure appeared later as an authentication error, not a parse error.

An upstream request to make substitution configurable had been open since 2024. A pull request arrived in 2026 but targeted an unreleased API. A migration tool cannot require users to recognize and escape parser syntax inside their secrets.

The original Rust dotenv crate stopped releasing in 2020 and was eventually marked unmaintained by RustSec, which listed dotenvy as an alternative.

Dotenvy’s description still calls it “a well-maintained fork.” Its latest published version, 0.15.7, was released on March 22, 2023. A Rust forum discussion noted the two-year release gap in 2025. By the time the bcrypt bug blocked SecretSpec, it was more than three years.

There is an uncomfortable irony in a maintained fork repeating its upstream’s release problem. Its maintainers do not owe us a release, but SecretSpec needed breaking fixes on a schedule we control.

We first considered a small patch. Auditing the parser uncovered more problems around JSON, Windows paths, Unicode names, precedence, and partial environment mutation.

dotenv-ng therefore starts from dotenvy 0.15.7 but deliberately breaks compatibility where correctness requires it. Version 1.0 adds:

  • a source-aware parser with structured errors;
  • literal dollar signs by default, with substitution available only when a caller explicitly enables it;
  • a broader key grammar that supports dashes, leading digits, leading dots, and Unicode;
  • a renderer that adds only the quoting and escaping needed to parse a value back unchanged;
  • validation before process-environment mutation; and
  • an explicit unsafe boundary around that mutation.

Property tests exercise arbitrary Unicode and syntax-heavy values, check that quoting is used only when necessary, and round-trip complete documents. The parser and renderer, the core of the rewrite, both have 100% line coverage.

The complete compatibility and API changes are recorded in the dotenv-ng 1.0 changelog.

The package is available on crates.io. Applications can keep the familiar dotenv crate name with a dependency alias:

[dependencies]
dotenv = { package = "dotenv-ng", version = "1" }

Starting in SecretSpec 0.20, dotenv-ng powers dotenv parsing and rendering throughout SecretSpec.

SecretSpec 0.19: Moving and importing secrets between providers

Secret storage changes as a project grows. Values move from local files to password managers, from one naming convention to another, and sometimes between providers with completely different data models. The application still expects the same API_KEY or DATABASE_URL at the end.

SecretSpec 0.19 treats those changes as a normal workflow instead of a one-off migration script.

This release includes:

  • Provider-specific storage layouts: give each provider its own address, transform stored values, import existing files, and preview exact write references.
  • Config belongs in secrets: resolve profile-specific config alongside stored secrets, generate ephemeral values, and securely prompt during secretspec run.
  • Passbolt provider: read and write secrets in a self-hosted Passbolt server, with credentials supplied by another provider when needed.
  • Faster remote-provider workflows: attach a cache directly to an authoritative provider and batch 1Password field reads.
  • Smaller improvements: create standalone profiles and install complete pkg-config metadata for native SDK consumers.

API_KEY is the name used by your application. In 1Password, the same value might be the token field of an item named old-api-item.

A ref gives SecretSpec this store address. item names the entry. Coordinates such as field, section, and vault locate a value inside structured stores. The providers list still decides which stores to try.

Before 0.19, every provider in a secret’s route received the same ref, even though stores such as 1Password and dotenv organize secrets differently. Now each provider alias can template its usual layout, while refs.<alias> handles exceptions:

secretspec.toml
[providers]
legacy = "onepassword://Legacy"
production = {
uri = "onepassword://Production",
ref = { item = "{project}-{profile}", field = "{key}" }
}
local = { uri = "dotenv://.env", ref = { item = "{key}" } }
[profiles.production]
API_KEY = {
description = "API key",
providers = ["production", "local"],
refs = { legacy = { item = "old-api-item", field = "token" } }
}

production reads the API_KEY field from a <project>-production 1Password item. The local fallback reads the dotenv key API_KEY. If legacy is selected explicitly, refs.legacy reads the token field from old-api-item.

For each provider, refs.<alias> takes precedence over the alias’s ref template, which takes precedence over the provider convention. Templates accept {project}, {profile}, and {key} in every address field. Existing route-wide ref declarations remain supported.

Because scoped references also apply to imports, that exception can describe a migration source without joining the normal fallback route:

Terminal window
secretspec import legacy --profile production --delete-source

This reads from refs.legacy and writes through the production template. It also works between distinct entries in one physical store. SecretSpec rejects the import if both addresses resolve to the same entry.

Two new secret fields transform a stored value before it reaches the application.

extract selects a value from JSON with an RFC 6901 JSON Pointer:

secretspec.toml
[providers]
runtime = "file:///run/secrets"
[profiles.production]
DATABASE_PASSWORD = {
description = "Database password",
providers = ["runtime"],
ref = { item = "application.json" },
extract = { format = "json", pointer = "/database/password" }
}

JSON strings become their unquoted contents. Numbers, booleans, objects, and arrays keep their JSON representation. Extracted declarations are read-only. set, delete, prompting, generation, and import cannot overwrite the source document.

encoding defines the textual representation in provider storage:

secretspec.toml
[profiles.production]
TEXT_CONFIG = { description = "Encoded configuration", encoding = "base64" }
CLIENT_KEYSTORE = {
description = "Binary client keystore",
providers = ["runtime"],
ref = { item = "client.p12.b64" },
encoding = "base64",
as_path = true
}

Supported encodings are standard Base64, URL-safe Base64, and hexadecimal. Writes encode the logical value. Reads decode the stored value. Decoded UTF-8 can be returned directly. Set as_path = true to materialize arbitrary bytes in a file.

Transforms run in this order:

provider or cache → encoding decode → JSON extraction → as_path

This allows, for example, one declaration to decode a Base64-encoded JSON document and select one field from it.

File stores one plaintext UTF-8 file per secret beneath a required root. Convention paths use {project}/{profile}/{key}. ref.item selects an existing relative path, including a file mounted at runtime.

Writes use atomic replacement and create private Unix files and directories. The provider rejects traversal and nested symlinks. It does not encrypt its contents.

The file provider is also a migration adapter for directories that already contain one file per secret. A provider ref template maps the source layout, while the destination alias independently maps the same declarations into its native store:

secretspec.toml
[providers]
legacy_files = {
uri = "file:./old-secrets",
ref = { item = "{profile}/{key}" }
}
production = {
uri = "onepassword://Production",
ref = { item = "{project}-{profile}", field = "{key}" }
}
[profiles.production.defaults]
providers = ["production"]
[profiles.production]
API_KEY = { description = "Production API key" }
Terminal window
secretspec import legacy_files --profile production --delete-source

For API_KEY, the source is old-secrets/production/API_KEY. The destination is the API_KEY field in the <project>-production 1Password item. The source files do not need to follow the SecretSpec convention. With --delete-source, 0.19 preflights every mapped source and destination, verifies every copied value, and only then removes the plaintext source files.

Preflight, write, or verification failures leave every source untouched. A destination with a different existing value keeps its corresponding source.

secretspec set and interactive secretspec check now print the resolved write reference before reading a value:

Terminal window
$ secretspec set API_KEY --profile production --provider sops://secrets.enc.yaml
Writing secret 'API_KEY' to sops://secrets.enc.yaml?format=yaml (profile: production)
target: /work/my-app/secrets.enc.yaml ["my-app"]["production"]["API_KEY"]
Enter value for API_KEY (profile: production): ********

SOPS reports the canonical encrypted file and exact sops set selector. Other providers report their native item or path. A missing profile or unexpected template is visible before SecretSpec receives the new value.

Null always reports a missing value and stores nothing. This lets manifest defaults provide non-sensitive values without adding a storage backend. One resolution can now return profile-specific configuration and provider-backed secrets together. This follows the separation described in Secrets Don’t Belong in Config.

secretspec.toml
[profiles.default]
APP_MODE = { description = "Application mode", default = "local", providers = ["null"] }
[profiles.staging]
APP_MODE = { default = "staging" }
[profiles.production]
APP_MODE = { default = "production" }

APP_MODE resolves to local, staging, or production based on the selected profile. Each override inherits the description and null route from [profiles.default]. Only the value is repeated.

The result is one declaration model for values the application needs, whether they come from a secret store or directly from the manifest. Config can travel through the same profile, scope, SDK, and run workflow without pretending it needs encrypted persistence.

The null provider can also generate a fresh value for each resolution. Use it for session tokens, test credentials, and other values that should exist only for one process invocation. Persistent credentials should continue to use a writable provider.

Set prompt = true on a declaration to let secretspec run securely request its value when the configured providers do not have one:

secretspec.toml
[profiles.default]
DEPLOY_PASSWORD = {
description = "One-time deployment password",
prompt = true,
providers = ["null"]
}
Terminal window
$ secretspec run -- ./deploy
? Enter value for DEPLOY_PASSWORD (profile: default):

A writable provider saves the answer, turning the prompt into first-use provisioning. The null provider keeps it ephemeral and injects it only into that invocation. The hidden prompt reads from the controlling terminal, so the child’s stdin remains available for pipes and redirects. If no controlling terminal exists, run fails before starting the child. Declarations without prompt = true retain the existing fail-on-missing behavior.

Passbolt is the third new provider in 0.19. SecretSpec now has 27 providers.

Passbolt reads and writes resources in a self-hosted Passbolt server through go-passbolt-cli. Convention values use the resource secretspec/{project}/{profile}/{key} and its password field. References can select existing resources by UUID or exact name and address the password, username, uri, or description field.

secretspec.toml
[providers]
bootstrap = "keyring://"
[providers.passbolt_team]
uri = "passbolt://?server=https://pass.example.com&folder=a9230ec4-5507-4870-b8b5-b3f500587e4c"
credentials = { private_key = "bootstrap", passphrase = "bootstrap" }

The OpenPGP private key and passphrase can come from another SecretSpec provider. Environment fallbacks and the Passbolt CLI configuration are also supported. Folder-scoped providers support declaration discovery with init --from.

Remote secret reads pay for authentication, process startup, and network round-trips before the application can start. SecretSpec 0.19 reduces that work both across invocations and within one resolution.

A single authoritative provider can now define uri, credentials, and cache on the same alias:

secretspec.toml
[providers]
local = "keyring://secretspec/cache/{project}/{profile}/{key}"
azure = {
uri = "akv://team-vault",
credentials = { client_secret = "keyring" },
cache = { provider = "local", max_age = "8h" }
}
[profiles.development.defaults]
providers = ["azure"]

The cached fallback form introduced in 0.17 remains available when several authoritative providers can answer.

Cache entries now include their absolute expiration time and originating max_age. SecretSpec removes an expired entry whenever it encounters one, and changing max_age invalidates entries written under the previous policy.

Fallback resolution also reuses provider instances and handles independent primary misses concurrently. Azure Key Vault reuses its client and serializes initial challenge-based authentication, avoiding repeated Azure CLI processes within one resolution.

1Password field references now resolve together through one op inject call, instead of starting op read separately for every field. This reduces CLI startup and repeated unlock overhead when one profile loads several fields. If the batch contains a missing reference, SecretSpec falls back to bounded concurrent reads so it can preserve per-secret missing-value behavior without serializing the whole profile.

In the cold-cache benchmark from the implementation PR, a representative profile with 25 field references resolved in 11.890 seconds, down from 96.294 seconds. The batch used 3 op processes instead of 27, making that run 8.10 times faster.

Profiles inherit [profiles.default] unless their defaults set inherit = false:

secretspec.toml
[profiles.default]
DEV_DATABASE_URL = { description = "Developer database" }
LOCAL_DEBUG_TOKEN = { description = "Local debugging token", required = false }
[profiles.production.defaults]
inherit = false
providers = ["vault://vault.example.com:8200/secret"]
[profiles.production]
DATABASE_URL = { description = "Production database" }
API_KEY = { description = "Production API key" }

production contains only its own declarations and fields. Other profiles in the same manifest can continue to inherit the default profile.

This section describes the 0.19 names. In SecretSpec 0.20+, the component is named libsecretspec, installed with cargo cinstall -p libsecretspec, and provides libsecretspec.pc.

cargo cinstall -p secretspec-ffi now installs the library, C header, and a secretspec_ffi.pc file containing the complete link metadata. Go builds can use the pkgconfig tag, Ruby native extensions accept --enable-pkg-config, and Haskell builds use the use-pkg-config Cabal flag. The same metadata supports installed static or shared libraries.

Haskell now declares its required macOS system frameworks. The Rust SDK’s ProviderAlias type also exposes leaf, credentials, and credentials_mut helpers for configuration tooling.

Terminal window
cargo install secretspec

Existing route-wide ref declarations, inheriting profiles, and cached fallback aliases remain compatible. All new configuration fields and providers are opt-in.

0.19 also:

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

These items are not part of 0.19. They are open work for future releases:

  • Native Windows ARM64 CLI archive (target: 0.19.1): add secretspec-aarch64-pc-windows-msvc.zip and its checksum to GitHub Releases so the CLI can run natively on Windows ARM64. The static installer will continue to select the x64 build on Windows ARM devices until it supports the native archive, and standalone updates depend on axoupdater supporting Windows ARM64.
  • WinGet packaging: publish the initial package tracked in microsoft/winget-pkgs#413776, then automate stable updates through SecretSpec #297.
  • Notification and approval integrations: send new secret access requests to services such as email, Slack, or WhatsApp for approval.
  • JVM SDK: expose the shared SecretSpec resolver to Java, Kotlin, and other JVM languages.
  • Dart SDK: bring the shared resolver to Dart and Flutter applications.

Every team has a secrets story. Come tell us yours on Discord.