Skip to content

Blog

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.

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.

Where .env Went Wrong

.env is one of software’s most successful accidents.

It starts as a shortcut for three export commands. Then it becomes the project’s configuration schema, secret store, environment model, onboarding guide, CI interface, and deployment format.

Environment variables do one job well: deliver strings to a process. .env turned that delivery mechanism into a source of truth.

A convenience became architecture. That is where .env went wrong.

Environment variables solve a small problem: getting values into a running process. The application can read DATABASE_URL without knowing whether a developer, CI system, or secrets manager supplied it.

A .env file makes those values easy to save and reload. That is useful. But teams also use the file to describe what the application needs. KEY=value cannot say whether a value is required, secret, safe to commit, available only in production, or restricted to one service.

Those requirements outlive any process and any developer laptop. They belong in a durable project declaration. .env stores values for delivery; it cannot define the application’s secret model.

Consider a typical example file:

.env.example
DATABASE_URL=
REDIS_URL=redis://localhost:6379
STRIPE_API_KEY=
DEBUG=false

The file raises more questions than it answers. Does an empty value mean required or optional? Is REDIS_URL a development default? Is STRIPE_API_KEY production-only? Is DEBUG a boolean?

Dotenv cannot encode those answers. Node.js documents that every value becomes a string. A dotenv issue about booleans, opened in 2015, still collects reactions from developers surprised that "false" is truthy.

Teams put the missing information elsewhere: validation code, a README, .env.example, or a teammate’s memory. These sources drift.

The file also makes DEBUG and STRIPE_API_KEY look equivalent. One is an ordinary setting that belongs in Git. The other grants authority and needs access control and rotation. Mixing them makes the whole file sensitive.

Without an explicit declaration, missing values fail late: the application discovers them only when code tries to use them.

A new requirement usually creates another file:

.env
.env.local
.env.development
.env.development.local
.env.test
.env.production

The filenames become an environment model. Suffixes define scope, load order defines inheritance, and copying a file becomes deployment.

This reverses the Twelve-Factor App’s guidance. Its point was that environment variables should be independent controls because named environments become brittle as deployments multiply. .env.production recreates that grouping in a filename.

Now every new value must be added to .env.example, documented in a README, validated in code, and copied into the right real files. Miss one and the environments drift.

.env looks standardized, but every parser defines its own format. Node.js documents the lack of a formal specification, as does python-dotenv. Each loader makes its own choices.

python-dotenv expands ${NAME} but not $NAME. Node dotenv delegates variable expansion to another tool. Docker Compose supports its own shell-style operators. Vite even supports references in reverse order, then warns that the same expression will not work in a shell or Docker Compose.

Comments and quotes differ too. Node dotenv changed the meaning of # in unquoted values in version 15 as a breaking change. One devenv user found that quotes became part of an exported key.

Parsers also disagree about precedence. Node dotenv normally lets the first file win. Docker Compose lets the last env_file win, then lets the environment section override that. Vite gives an existing process variable priority over its files.

Docker Compose gives two similar names different behavior. env_file: supplies variables to a container but does not use them to interpolate compose.yaml. docker compose --env-file does affect interpolation. In an issue closed as working as designed, a maintainer described the option’s name as unfortunately chosen.

Precedence also depends on timing. Node dotenv’s ES module guidance needs special handling when imported modules read the environment during initialization. Vite warns that Bun’s automatic .env loading can interfere with Vite’s own loading order. VITE_* values are replaced at build time and become part of the client bundle.

The same line can become a runtime secret, a build-time constant, or a public browser value. The loader decides based on timing and context.

At that point .env behaves like a small program, with control flow spread across filenames, flags, working directories, parent processes, and library versions.

The dotenv project says not to commit .env. .gitignore prevents one accident. It does not add encryption, access control, auditing, or revocation.

The file can still end up in editor backups, chat messages, archives, support bundles, container build contexts, and old laptops. A devenv integration was reported to copy .env contents into the Nix store, where paths are not confidential. When a developer leaves, there is no file access to revoke. Each credential they received is a separate copy.

Even a secret stored in 1Password, Vault, a cloud secret manager, or a system keyring must be copied into plaintext before a dotenv-based application can use it. The local copy has fewer controls than the original.

Environment-variable delivery has limits too. Docker mounts managed secrets as files because environment variables can leak between containers. A process also gets one global map, so a frontend build, worker, migration, and web service often receive the same secrets even when each needs only a few. Dotenv has no way to express that scope.

The useful part of .env is the short path from “this application needs a value” to “the application can run.”

Keep it as an adapter for tools that expect KEY=value, or use it for ordinary local settings. Do not make it define the project’s requirements, store durable copies of secrets, encode environments in filenames, or decide which services receive which values.

A durable design separates three jobs:

  • a committed declaration says which secrets the application needs;
  • protected storage controls who can read their values;
  • explicit delivery gives each process only the values it needs.

Each piece can then change independently. A team can change storage without rewriting the application, validate requirements before startup, and limit each component to its own secrets.

SecretSpec puts the declaration in a file that is safe to commit:

secretspec.toml
[project]
name = "payments"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "Postgres connection string" }
REDIS_URL = { description = "Redis connection string", required = false }
STRIPE_API_KEY = { description = "Stripe API key" }
[profiles.development]
REDIS_URL = { default = "redis://localhost:6379" }

This file records requirements, defaults, and descriptions without containing secret values. Providers choose where values live, and profiles describe real differences in requirements.

Existing programs can adopt SecretSpec without code changes:

Terminal window
$ secretspec run -- ./server

This command injects resolved secrets into the child process environment. It is useful during migration, while the preferred integration is a SecretSpec SDK.

With an SDK, the application resolves its declaration directly. This removes the environment-variable handoff used by secretspec run. Values stored in a keyring, password manager, or Vault never enter the global process environment. Applications that require a file can receive a temporary file instead. Scopes (0.17+) let each component resolve only the secrets it declares.

Migration can be gradual. SecretSpec initializes a declaration from an existing file:

Terminal window
$ secretspec init --from dotenv:.env

This copies names without copying values. The current file can remain a provider during the transition:

Terminal window
$ secretspec check --provider dotenv:.env
$ secretspec run --provider dotenv:.env -- ./server

Values can then move to a system keyring, password manager, Vault, or another provider without changing the names the application reads.

.env can remain for ordinary local settings. Existing applications can keep environment-variable delivery while they migrate. Applications using an SDK or file-based delivery can remove secrets from their process environments.

SecretSpec aims to eliminate environment variables for secrets altogether.

SecretSpec 0.17: Scopes, secrets caching, SOPS, age, and systemd credentials

SecretSpec 0.17 ships:

A profile describes how secrets resolve for an environment. A scope now describes which of those secrets one consumer may receive:

secretspec.toml
[profiles.default]
DATABASE_URL = { description = "Database" }
API_KEY = { description = "API key" }
QUEUE_TOKEN = { description = "Queue token" }
[scopes.api]
secrets = ["DATABASE_URL", "API_KEY"]
[scopes.worker]
secrets = ["DATABASE_URL", "QUEUE_TOKEN"]
Terminal window
$ secretspec run --scope api -- ./api
$ secretspec run --scope worker -- ./worker

Composed secrets may still read hidden dependencies to build a visible value, but those inputs are not exposed to the child. The same scope selection is available to check, export, and the SDK builders. Scopes minimize secret delivery; they are not an authorization boundary when the child itself holds provider credentials.

Carrying the selected scope through resolver requests and results required a breaking change to secretspec-ffi. All SecretSpec SDKs have been updated for 0.17 to support scopes, so applications should upgrade their SDK package and bundled native resolver together.

Many cloud providers take long enough to resolve a secret that their latency becomes part of every development command.

A single 1Password lookup can take roughly one second.

SecretSpec providers implement get_many so a backend can resolve several values together. Relatively few secret stores and CLIs expose a true bulk-read operation, however, so many providers still have to perform separate lookups.

Waiting on a remote service or its CLI every time makes check, run, and application startup feel slow, especially as a project grows.

A provider alias can now combine its authoritative fallback route with a local cache:

secretspec.toml
[providers]
vault = "vault://vault.example.com:8200/secret"
local = "keyring://secretspec/cache/{project}/{profile}/{key}"
fast_vault = {
fallback = ["vault"],
cache = { provider = "local", max_age = "8h" }
}
[profiles.default.defaults]
providers = ["fast_vault"]

Fresh entries avoid contacting the remote provider. A miss or expired entry falls through to Vault and refreshes the cache; writes update the authoritative provider first and then refresh or invalidate its cached copy. Route changes, reference changes, and writes that bypass the cached alias also invalidate the entry.

The cache is a real copy of the secret, so SecretSpec requires a distinct store that it can delete from and records ownership before changing an entry. secretspec cache clear [NAME] forces the next read back through the authoritative route.

Vault and OpenBao KV v2 caches are the only providers that handle max_age as server-side expiry properly.

None of SecretSpec’s current local providers has strong native support for expiry. They can remove an expired entry the next time SecretSpec sees it, but cannot ensure the local copy disappears at its deadline if SecretSpec never runs again.

Our planned FactorSeal provider in Future work is intended to close that gap with an explicit API for credential eviction among the other goals.

Profiles can now express credential alternatives directly:

secretspec.toml
[profiles.default]
PASSWORD = { description = "Password", required = { at_least_one = "auth" } }
ACCESS_TOKEN = { description = "Token", required = { at_least_one = "auth" } }
GITHUB_TOKEN = { description = "GitHub token", required = { exactly_one = "github_auth" } }
GITHUB_APP_KEY = { description = "GitHub App private key", required = { exactly_one = "github_auth" } }

The auth group accepts a password, an access token, or both. The github_auth group requires exactly one credential and rejects configurations that provide both the token and the app key.

SOPS brings the encrypted-file workflow from our recent SOPS comparison behind SecretSpec’s provider-independent CLI and SDKs. SecretSpec delegates encryption and decryption to the installed SOPS CLI, so existing SOPS key services and .sops.yaml creation rules remain in control.

The provider reads and writes YAML, JSON, dotenv, and INI files, supports a single shared file or {project} / {profile} path templates, and can source sensitive SOPS inputs such as age keys or cloud credentials through provider credentials.

secretspec.toml
[providers]
sops = "sops://secrets/{project}/{profile}.enc.yaml"
[profiles.production.defaults]
providers = ["sops"]

age offers a smaller encrypted-file setup. It stores a dotenv-style secret set for one or more age recipients, including hybrid post-quantum recipients.

KeePass KDBX reads KDBX 3 and 4 databases and writes KDBX 4, with master passwords sourced from another provider rather than embedded in the URI.

OpenBao gets its own openbao:// identity and BAO_* configuration while sharing compatible KV, token, AppRole, and JWT mechanics with Vault. Both Vault and OpenBao can now exchange a JWT for a short-lived token, including an OIDC token minted automatically in GitHub Actions and Forgejo Actions with id-token: write.

Scaleway Secret Manager adds regional, project-aware cloud storage and read-only references to existing secrets and revisions.

systemd credentials is a read-only provider that resolves values from the current service’s $CREDENTIALS_DIRECTORY, including credentials used to bootstrap another provider.

Alongside 0.17, the new cachix/secretspec-action installs SecretSpec, resolves the selected profile, masks every value in the runner log, and adds the secrets to the environment of later job steps:

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: cachix/secretspec-action@main
with:
profile: production
scope: api
- run: ./deploy.sh

A missing required secret fails the action, so the same step also checks that the deployment environment is complete. See the GitHub Actions guide for provider selection and tokenless Vault or OpenBao authentication through the runner’s OIDC identity.

SecretSpec 0.17 also brings:

  • Bitwarden Secrets Manager — now uses the separately installed official bws CLI instead of linking its SDK.
  • Non-interactive setupsecretspec config global init --provider ... --profile ... configures defaults without prompts.
  • Windows packages — for the Python, Ruby, and PHP SDKs.
  • Clearer status output — plus more controlled concurrency and retry behavior for Vault and OpenBao.
Terminal window
$ cargo install secretspec

See the full changelog for every change in this release.

The next work brings more control to local secret access:

  • GUI confirmation dialogs — approve or deny a secret request in a native prompt instead of requiring a terminal interaction.
  • A Passbolt provider (0.19+) — bring Passbolt’s open-source, collaboration-focused credential manager behind the same SecretSpec interface for cloud and self-hosted teams.
  • A Bitwarden Password Manager provider — resolve regular Bitwarden vault items, separately from the Bitwarden Secrets Manager provider already available in SecretSpec.
  • A JVM SDK — work is underway to bring the shared SecretSpec resolver to Java, Kotlin, and other JVM languages.
  • A FactorSeal provider — we have started work on a new Linux provider built around mandatory TPM-backed storage and secure defaults. FactorSeal also provides an explicit API for credential expiry, which is crucial for the caching work in this release: local copies can carry a defined eviction deadline instead of living without a retention policy. Still in development.

Questions or feedback? Join us on Discord.

But I Use SOPS

Whenever I show someone SecretSpec, I often hear the same response:

But I use SOPS.

SOPS is good. It encrypts files so they can live in Git without exposing their plaintext values.

But SecretSpec solves a different problem: how applications declare, find, and consume secrets.

Once you have encrypted secrets.yaml, how does your Python service consume it? What about your Go worker or Node.js app?

You still need to decrypt the file, inject its values, select the right file for each environment, validate required keys, and repeat that integration for every language.

And if you release the project as open source, that choice does not stay yours. With SOPS baked into the setup, everyone who runs or contributes to the project must adopt SOPS and its key management, whatever secrets tooling they already use.

SecretSpec starts at the other end. The project declares what the application needs without storing any values:

secretspec.toml
[project]
name = "payments"
revision = "1.0"
[profiles.default]
DATABASE_URL = { description = "Postgres connection string" }
STRIPE_API_KEY = { description = "Stripe secret key" }

The same secret can come from a developer’s system keyring or CI environment variables, while a more sensitive production environment resolves it from Vault. Applications use the same declaration through nine SDKs for Rust, Python, Go, Ruby, Node.js/TypeScript, Haskell, PHP, C#, and Swift (0.18+) without knowing the provider.

Encrypted files also make the key workflow a project-wide requirement. Adding a teammate means adding their key to .sops.yaml and re-encrypting every file; removing one means rekeying and rotating the affected secrets, since their key already saw the plaintext. SecretSpec leaves identity and access to the provider: onboarding to Vault or a cloud secrets manager is granting a role, and offboarding is revoking it.

SOPS may be enough today. As your team grows more sensitive to how secrets are handled, you may want Vault’s access policies and centralized audit trail. If applications know about SOPS, each one needs migrating. If they know only SecretSpec, you change the provider configuration; SDK calls and secret names stay the same.

The same resolver provides profiles, required-secret checks, per-secret provider routing and fallback, provider-native references, temporary files, and metadata-only audit logs. You build the integration once, not once per provider and language.

SOPS protects a file. SecretSpec gives applications a provider-independent interface. The selected provider remains responsible for storage, encryption, identity, access control, and availability.

I wrote a fuller SecretSpec comparison showing exactly where SecretSpec ends, where providers begin, and which responsibilities belong to each layer.

Because applications talk to an interface instead of a file, the interface can grow without touching them. Three open proposals point where it is heading:

  • Project security requirements would let a project declare the guarantees a provider must meet, such as encryption at rest or an audit trail, and reject providers that fall short.
  • Lease-aware refresh would let running applications follow key rotation and short-lived credentials instead of restarting for a new value.
  • The SOPS provider (0.17+) brings SOPS itself behind the same SDK interface, making your encrypted files one more place secrets can come from.

With that provider, perhaps “But I use SOPS” just needs two more words:

But I use SOPS with SecretSpec.

If encrypted files fit your workflow, keep using SOPS. Just recognize the boundary: encryption at rest is not an application secrets interface.