Skip to content

Blog

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.

SecretSpec 0.18: Secret lifecycle, Bitwarden, Keeper, AWS Parameter Store, and Swift

SecretSpec 0.18 ships:

The new SecretSpec document-and-keyhole logo

SecretSpec has always kept the declaration in secretspec.toml separate from the stored value. 0.18 brings both sides of that lifecycle into the CLI.

secretspec add adds a declaration to the selected profile while preserving the manifest’s comments, formatting, and unrelated tables:

Terminal window
$ secretspec add STRIPE_API_KEY --description "Stripe API access token"
✓ Added secret 'STRIPE_API_KEY' to profile 'default' in secretspec.toml
Set its value with: secretspec set STRIPE_API_KEY --profile default
$ secretspec set STRIPE_API_KEY
Enter value for STRIPE_API_KEY: ********
✓ Secret 'STRIPE_API_KEY' saved to keyring (profile: default)

add never asks for or stores the value. The declaration can be reviewed and committed before each developer or deployment supplies its own value.

secretspec delete does the inverse on the storage side: it removes a value without changing the declaration. The next check therefore reports the secret as missing instead of quietly removing the application’s requirement:

Terminal window
$ secretspec delete STRIPE_API_KEY
Deleted 'STRIPE_API_KEY'
Deleted 1 secret value; 0 already absent

Deletion is idempotent, invalidates an associated cache entry, and follows the same primary-write-provider routing as set. delete --all requires an interactive confirmation, or an explicit --yes in non-interactive use.

Provider migrations can now remove each source value after proving the move succeeded:

Terminal window
$ secretspec import dotenv:~/.config/payments/.env --delete-source

import --delete-source reads the destination back and compares it with the source before deleting anything. An identical value already at the destination is safe to remove from the source; a conflicting value leaves the source intact. SecretSpec also rejects a source without deletion support before writing the destination and recognizes equivalent provider spellings as the same store, so a migration cannot delete the value it just wrote through another alias.

Together these commands keep the distinction explicit: add changes what the application declares, set and delete change one environment’s stored value, and import --delete-source moves that value between stores.

The first SecretSpec command in an existing project is often secretspec init --from .env. In 0.18, init --from accepts every provider that can discover declarations, including age, AWS Parameter Store, and Bitwarden Password Manager.

Hierarchical stores also receive an explicit project and profile so SecretSpec looks only inside the namespace the new manifest will use:

Terminal window
$ secretspec init \
--from 'awsps://production@us-east-1?template=/{profile}/{project}/{key}' \
--project payments \
--profile production
✓ Created secretspec.toml with 12 secrets

For a password-manager vault, scope discovery to the collection and item type that belong to the application:

Terminal window
$ secretspec init --from 'bw://Acme%20Inc@dev-secrets?type=login'
✓ Created secretspec.toml with 8 secrets

Discovery writes names and generated descriptions, never secret values. After reviewing the manifest, keep the discovered provider as the profile’s source or use secretspec import to copy the now-declared values somewhere else.

0.18 brings SecretSpec to 24 providers, with four additions spanning personal password managers, machine-oriented vaults, and cloud parameter storage.

Bitwarden Password Manager is separate from the existing Bitwarden Secrets Manager provider. The new bw:// provider uses the official bw CLI to read and write regular vault items: logins, secure notes, cards, identities, and SSH keys. It can address organizations and collections by name or ID, restrict a provider to one item type or field, discover declarations, and point ref secrets at existing items. A ?server= guard verifies that the CLI is logged into the expected self-hosted instance instead of silently reading the wrong vault.

Keeper Secrets Manager uses Keeper’s official Rust SDK, so it does not need a separate CLI. A keeper://FOLDER_UID provider reads, writes, batches, and deletes convention records shared with a KSM application; refs can select an existing record and field. Its client configuration can come from KSM_CONFIG, a protected configuration file, or SecretSpec provider credentials.

AWS Systems Manager Parameter Store stores every value as a KMS-encrypted SecureString. The awsps:// provider uses the standard AWS credential and region chains and supports shared-config profiles, hierarchy prefixes, complete {project} / {profile} / {key} templates, customer-managed KMS keys, and parameter tiers. Refs can select an existing parameter by name, version, label, or ARN; unversioned name refs are writable, while pinned revisions remain read-only. Its bounded hierarchy discovery uses GetParametersByPath without decrypting values.

Dashlane reads secrets, secure notes, and logins through the dcli CLI. It is intentionally read-only because dcli cannot create or edit vault items. A ref can address an existing item by title or identifier and select one of its fields. CI can provide DASHLANE_SERVICE_DEVICE_KEYS directly or source the same service_device_keys input from another SecretSpec provider.

A project can route different secrets through any combination of them:

secretspec.toml
[providers]
team_vault = "bw://Acme%20Inc@dev-secrets"
keeper_ci = "keeper://SHARED_FOLDER_UID"
parameters = "awsps://production@us-east-1?prefix=/platform"
dashlane_notes = "dashlane://note"

Provider choice still stays outside application code. The CLI and every SDK resolve the same declaration regardless of which of these aliases supplies a value.

The new Swift SDK brings the shared SecretSpec resolver to macOS 12 or later on Intel and Apple silicon. Add the repository as a Swift package:

dependencies: [
.package(
url: "https://github.com/cachix/secretspec",
from: "0.18.0"
),
]

Then use the same builder vocabulary as the other SDKs:

import SecretSpec
let resolved = try SecretSpec.builder()
.withProfile("production")
.withScope("api")
.withReason("boot web app")
.load()
defer { try? resolved.close() }
print(resolved.secrets["DATABASE_URL"]?.get() ?? "")
try resolved.setAsEnvironment()

The SDK exposes fluent and one-shot resolution, typed failures, value-free preflight reports, scopes, provenance, environment export, and JSON input for generated Swift models. Calling close() deterministically removes temporary files created for as_path secrets.

The SwiftPM release contains a checksummed XCFramework with the Rust resolver, so an application needs neither a Rust toolchain nor a separately installed SecretSpec library.

Vault and OpenBao deployments do not always use the default approle and jwt mount names. Their provider URIs can now choose a mount relative to /v1/auth:

vault://vault.example.com:8200/secret?auth=approle&auth_mount=platform-approle
openbao://bao.example.com:8200/secret?auth=jwt&auth_mount=ci-jwt&role=deploy

AppRole authentication can omit secret_id when the server role is configured with bind_secret_id=false. JWT authentication can likewise omit its role when the selected mount has a server-configured default_role. Explicit URI, environment, or provider-credential inputs continue to take precedence.

Terminal window
$ cargo install secretspec

0.18 also makes two local workflows less dependent on machine-specific setup:

  • custom dotenv paths accept a leading ~, resolved to the current user’s home directory;
  • Linux keyring builds use keyring 4’s Rust-native Secret Service transport, so SecretSpec binaries no longer require system libdbus.

Existing manifests and providers continue to work unchanged. The new commands, providers, and SDK are opt-in.

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

Questions or feedback? Join us on Discord.