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.
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.
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.
Applications should not require passwords, API keys, or tokens in their
configuration files.
Configuration describes behavior. It belongs in git, code review, bug reports,
and developer machines.
A secret grants authority. It needs restricted access and independent rotation.
Putting both in one file couples different lifecycles and audiences. If rotating
a password requires regenerating application configuration, the interface has
coupled them too tightly.
The interesting number is 110. A quarter of the modules retrieve a secret
safely, then copy it into configuration because that is the only interface the
application accepts.
These modules use envsubst, replace-secret, jq, yq, sed, or custom
code to assemble a restricted file at startup. The result can be secure, but
every module now owns application-specific, security-sensitive glue just to
combine two inputs that should have remained separate.
This is not unique to NixOS. The same workaround appears as an entrypoint
script, Helm template, init container, or CI interpolation step on other
platforms.
As a side note, 42 modules can inline secrets into the world-readable
/nix/store. That direct security problem is tracked in
nixpkgs issue #24288. The 110
runtime mergers make the broader point: even when deployment authors avoid the
leak, the missing separation still creates work.
Applications should accept secret values through a dedicated runtime channel,
such as:
a password_file or token_file setting;
a systemd credential;
a narrowly scoped environment variable;
or an external secret provider.
These mechanisms are not equally safe: environment variables can be inherited,
arguments can appear in process listings, and files still need correct
permissions. What separation does guarantee is that the deployer no longer has
to manufacture a second, secret-bearing version of the configuration.
The principle is simple; implementing it across environments is not. Local
development might use a system keyring, CI environment variables, and
production 1Password or Vault. Without a shared abstraction, each environment
needs its own naming, lookup, validation, and injection glue.
Cachix historically stored its auth token and per-cache signing keys in
~/.config/cachix/cachix.dhall, alongside cache names and other configuration.
It was convenient, but the file had to be treated as a secret even though much
of it was ordinary configuration.
A typical file mixed them directly:
~/.config/cachix/cachix.dhall
{ authToken = "XXX-AUTH-TOKEN"
, binaryCaches =
[ { name = "mycache"
, secretKey = "XXX-SIGNING-KEY"
}
]
}
The cache name is configuration; the auth token and signing key are secrets.
You could not share the cache configuration without also sharing credentials.
devenv 2.2 separates the token through SecretSpec.
The project declares CACHIX_AUTH_TOKEN, devenv resolves it from the configured
provider, and the value is passed to Cachix without being added to devenv’s
configuration.
Cachix PR #737 brings the same
boundary into the client through the SecretSpec Haskell SDK. It resolves
CACHIX_AUTH_TOKEN and CACHIX_SIGNING_KEY from SecretSpec and can store them
in the user’s chosen provider instead of cachix.dhall. Existing environment
variables and config files remain higher-priority fallbacks for compatibility.
The PR is still open.
That is the problem SecretSpec is designed to solve: configuration declares the
requirement, while each environment chooses where the value lives.
Providers decide where the values live. A developer can
use the system keyring, CI can use environment variables, and production can use
1Password, Vault/OpenBao, or a cloud secret manager without changing the
declaration.
An existing application can receive the resolved values at startup:
Terminal window
$secretspecrun--./myapp
Applications can also resolve them directly through the
SecretSpec SDKs for Rust, Python, Go, Ruby,
Node.js/TypeScript, Haskell, PHP, C#, and Swift (0.18+), all sharing the same
resolver so behavior stays consistent across languages.
Providers own where secret values come from. SDKs give applications an
idiomatic way to consume them. Configuration remains a shareable declaration of
what is required.
If you maintain an application, stop adding passwords and tokens to ordinary
configuration schemas. Accept a file reference, credential, environment
variable, or provider instead.
For NixOS, SecretSpec issue #65
tracks how an official integration could declare and resolve secrets without
per-module substitution glue.
Consistent secret handling across developer machines, CI, and production used
to require infrastructure that only dedicated platform teams could build. A
project of any size should be able to separate secrets from configuration
without building its own secrets platform first.
Composed secrets — derive a read-only
value, such as a connection string, from other secrets declared in the
manifest.
Infisical — read and write secrets in Infisical
Cloud or a self-hosted instance, with Universal Auth, access-token, and
provider-credential authentication.
C# SDK — resolve the same manifests from .NET through the
shared native resolver, distributed as the Cachix.SecretSpec NuGet package.
Applications often need a connection string while secret stores work better
with its independently rotated parts. SecretSpec can now keep those parts
separate and assemble the application-facing value when it resolves the
manifest:
DB_USER, DB_PASSWORD, and DB_HOST still come from their configured
providers. DATABASE_URL is assembled in memory and behaves like any other
resolved secret in the CLI and SDKs. Compositions are read-only, may build on
other compositions, and are checked for missing references and cycles before
resolution.
See Composed Secrets for optional values,
escaping, profile inheritance, and validation rules.
The new infisical:// provider works with Infisical Cloud, its EU service, and
self-hosted instances. Point SecretSpec at an Infisical project and authenticate
with Universal Auth:
Access tokens are also supported. Credentials can come from environment
variables or SecretSpec’s
provider credentials, allowing,
for example, an Infisical machine identity to be kept in the system keyring:
secretspec.toml
[providers.infisical]
uri = "infisical://app.infisical.com/7e2f1a4c-..."
[providers.infisical.credentials]
client_id = "keyring"
client_secret = "keyring"
By default, the active SecretSpec profile also names the Infisical environment.
A production profile therefore reads from the production environment,
while ?env= can select a different one. The provider supports normal
SecretSpec reads and writes, as well as references to existing Infisical
secrets and versions.
See the Infisical provider guide for self-hosting,
authentication, paths, references, and permissions.
It uses the same resolver as the CLI and other language SDKs, so profiles,
providers, fallback chains, references, generators, audit reasons, and composed
secrets work consistently in .NET. Native resolver builds are included in the
NuGet package, with no separate SecretSpec CLI installation required.
See the C# SDK guide for supported platforms, ASP.NET Core
integration, preflight reports, error handling, and typed access.
All three additions are opt-in: existing manifests and provider configurations
continue to work unchanged. Add composed when a value should be derived,
select an infisical:// provider to use Infisical, or install
Cachix.SecretSpec in a .NET application.
See the full changelog
for every change in this release.
Suppose Bitwarden Secrets Manager holds an application’s
secrets, but its machine access token is kept in the user’s
OS keyring. Declare the relationship on the provider
alias:
secretspec.toml
[providers]
keyring = "keyring://"
[providers.bws]
uri = "bws://a9230ec4-5507-4870-b8b5-b3f500587e4c"
[providers.bws.credentials]
access_token = "keyring"
Before SecretSpec connects to Bitwarden, it reads access_token from the
keyring at the normal {project}/{profile}/access_token address. The active
profile is part of that address, so production and
development can authenticate as different machines without changing the alias.
When a credential already has a provider-native address, use a ref. Here a
Vault AppRole is kept as two fields of one
1Password item:
secretspec.toml
[providers.vault_prod]
uri = "vault://secret/myapp?auth=approle"
[providers.vault_prod.credentials]
role_id.provider = "onepassword"
role_id.ref.vault = "Infra"
role_id.ref.item = "vault-approle"
role_id.ref.field = "role_id"
secret_id.provider = "onepassword"
secret_id.ref.vault = "Infra"
secret_id.ref.item = "vault-approle"
secret_id.ref.field = "secret_id"
The credential source uses the same ref coordinates
as application secrets. The difference is where the value goes: SecretSpec
hands it directly to the destination provider in memory. It is not added to the
environment of a process started by
secretspec run.
Provider credential names are semantic and checked before a source is opened.
Bitwarden accepts access_token; Vault accepts token, role_id, and
secret_id; 1Password accepts service_account_token; Azure Key Vault accepts
tenant_id, client_id, and client_secret. A configured credential is
authoritative, while a provider’s usual environment fallback remains available
when no credential source is declared.
Credential chains deliberately stop after one hop. The store containing a
provider credential cannot itself depend on another provider credential. This
keeps the bootstrap path finite and makes dependency mistakes fail before any
store is contacted.
Credentials are fetched once per invocation and profile, then reused for every
secret routed through that alias. Each credential read, and each value stored by
login, gets an audit event marked with the semantic
credential name and source store. As with every SecretSpec audit event, the
credential value is never recorded.
The default authentication mode first looks for the tenant_id, client_id,
and client_secret provider credentials introduced above, then their
AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET environment
fallbacks. If none are present, it uses the signed-in Azure CLI or Azure
Developer CLI session. A partial service principal is an error, rather than a
reason to silently switch identities.
That makes a service principal straightforward to keep in the system keyring:
secretspec.toml
[providers.azure]
uri = "akv://myvault"
[providers.azure.credentials]
tenant_id = "keyring"
client_id = "keyring"
client_secret = "keyring"
Terminal window
$secretspecconfigproviderloginazure
$secretspecrun--providerazure--./deploy
Sovereign clouds can use either a complete vault hostname or an explicit DNS
suffix such as akv://myvault?suffix=vault.azure.cn.
Azure restricts secret names to letters, digits, and hyphens and compares them
case-insensitively. SecretSpec encodes the project, profile, and key as
lowercase, unpadded Base32 components. The encoding keeps names that differ by
case or punctuation distinct instead of letting Azure collapse them onto the
same secret. Existing Azure secrets can be addressed with a read-only ref.
The new gopass:// provider reads and writes through the
gopass CLI. Gopass builds on the Unix pass provider with
multi-user and multi-store support while keeping entries GPG-encrypted and
synchronized through git.
Once Gopass is installed and its password store is initialized, select it like
any other provider:
Terminal window
$secretspecsetDATABASE_URL--providergopass
$secretspecrun--providergopass--npmstart
By default, entries live under
secretspec/{project}/{profile}/{key}. A custom URI can change that layout,
including omitting {project} to share secrets between repositories:
An existing Gopass entry can also be addressed directly with a
ref, including the mount-point prefix used by a
multi-store setup. See the Gopass provider guide for
installation, shared-store configuration, references, and current limitations.
The new cachix/secretspec Composer package brings the shared resolver to PHP:
Terminal window
$composerrequirecachix/secretspec
<?php
use Secretspec\SecretSpec;
$resolved=SecretSpec::builder()
->withProfile('production')
->withReason('boot web app')
->load();
echo$resolved->secrets['DATABASE_URL']->get();
$resolved->setAsEnv();
It offers two native backends behind the same PHP API. The recommended native
extension embeds the resolver and works under PHP-FPM without ffi.enable, like
ext-redis. An ext-ffi fallback loads the shared resolver at runtime for CLI
tools and local development. Both use the same Rust core as the CLI and the
other language SDKs, so profiles, providers, fallback chains, generators,
as_path,
audit reasons,
and typed missing-secret errors behave the same way.
setAsEnv() updates getenv(), $_ENV, and $_SERVER, which lets Laravel’s
env() helper and Symfony’s %env(...)% processors consume resolved secrets
during application boot. See the PHP SDK guide for installation and
framework examples.
AWS accounts often require a customer-managed KMS key or specific tags in the
same CreateSecret request. The
AWS Secrets Manager provider now accepts both on its URI:
kms_key_id and repeatable tag.NAME=VALUE parameters are applied only when
SecretSpec creates a secret. Updating an existing secret does not alter the key
or tags it was created with. This supports tag-on-create SCP and IAM guardrails
without turning routine secret updates into infrastructure changes.
The new export command resolves every secret for the active profile without
starting another process. Its default output can be evaluated by a POSIX shell:
Terminal window
$eval"$(secretspecexport--profileproduction)"
Use --format dotenv to write dotenv syntax or --format json to pass the
resolved values to another tool:
GitHub and Forgejo Actions can use --format gha. SecretSpec masks every value
in the runner log and appends it to $GITHUB_ENV, making the secrets available
to later steps and third-party actions:
- name: Export secrets
run: secretspec export --profile production --format gha
- name: Deploy
run: ./deploy
Like non-interactive check, export never prompts
and exits non-zero when a required secret is missing, so it can gate a CI job.
Export attempts are also recorded in the
audit log. See the
export CLI reference for every format and option.
0.15 also tightens the behavior around profiles and fallback chains:
Provider chains are
now walked strictly in order and resolved lazily. An
undefined alias or unreachable fallback is skipped with a warning only when a
read reaches it, so a later working provider can still answer.
Chain entries accept aliases, bare provider names such as
keyring, shorthand such as
dotenv:.env, and complete provider URIs.
A single destination provider rejects unsupported
ref coordinates before contacting the store.
Multi-provider chains still validate each destination as they reach it,
because an earlier store may support coordinates a later one does not.
Profile overrides inherit the base secret’s
description and generation type. Validation now uses the effective merged
secret while still catching real conflicts, such as combining
generate with a profile default.
run passes non-UTF-8 environment variables through to the child untouched,
and command output that previously depended on map order is now stable.
Prebuilt Node.js addons now target glibc 2.28 and statically
include libdbus, restoring support for Amazon Linux 2023, RHEL 8/9, and
similar distributions.
Existing providers retain their conventional environment authentication when
an alias does not declare credentials. Provider credentials are opt-in, and
credential dependency chains are limited to one hop.
See the full changelog
for every change and fix in this release.
SecretSpec keeps a secretspec.toml that declares what secrets an application
needs, and resolves the values from a provider: your system keyring, 1Password,
Vault, a .env file, and so on. Until now it stored every secret under a naming
convention it controlled, secretspec/{project}/{profile}/{key}, and that
convention was the only place it looked.
That works when SecretSpec created the secret. It does not when the secret
already exists under a name something else chose: a db item in a 1Password
vault, a myapp/config path at a Vault mount, an environment variable your
platform already sets. To manage such a secret you had to copy its value into
SecretSpec’s convention, leaving two copies to rotate, or leave it out of
SecretSpec entirely.
SecretSpec 0.14
introduces ref. A secret can name one that already exists, by the store’s own
coordinates, and SecretSpec reads and writes that secret in place:
DATABASE_URL now resolves from the password field of the 1Password item
db. SecretSpec does not prepend a project or profile, and does not create a
name of its own.
1Password will give you an address for that field: op://Production/db/password.
The obvious design is to accept that string in the config and be done. We built
that first and removed it.
A string like op://Production/db/password names the store and the secret at
the same time, which ties the secret to 1Password. The same reference cannot
then resolve from Vault in CI and 1Password on a laptop, cannot be redirected at
a .env fixture for a test run without editing the manifest, and does not
compose with a provider fallback chain, because the chain and the address
disagree about where the secret is.
SecretSpec already decides which store to use, through providers, profiles,
fallback chains, and the --provider override. A ref names only the secret
and leaves the store to that existing machinery.
A ref is a table, not a URL. Each key names a level of structure that some
stores have:
vault which container holds the item (1Password only)
└── item the store's own name for the secret (always required)
└── section a named group of fields (1Password only)
└── field one component inside the item (structured stores)
└── version which revision to read (GCSM only)
Only item is required, because every store names its secrets somehow. item
is the complete name: it replaces the whole convention path, with no project or
profile and no folder prefix prepended. ref = { item = "GITHUB_PAT" } on the
env provider reads the environment variable GITHUB_PAT and nothing else.
The other keys refine item for stores that have that structure. A .env key
holds a single value, so field on a dotenv ref is not meaningful. A Vault KV
entry is a map, so field is required. When a store has no equivalent for a
coordinate it reports an error naming that coordinate, rather than reading a
different secret:
Error: Provider operation failed: the env provider does not support the `field` coordinate. Drop `field` from the ref for `GITHUB_PAT`.
All eleven providers resolve refs, and each rejects the coordinates it cannot
represent. A store whose secrets have no internal parts gets that rejection from
shared code, without any per-provider work.
A ref supplies the name only. Which provider resolves it follows the normal
provider resolution order: a --provider override, then
the secret’s providers chain, then profile and global defaults. That is the
same order every other secret uses.
Because the store is not part of the reference, the same ref works across
providers. Each provider in a fallback chain is asked for the same coordinates,
and one that cannot interpret them logs a warning and the chain continues:
Chain entries can also be inline scheme:// URIs, as above, with no
[providers] alias declared first.
The --provider override redirects a referenced secret the same way it
redirects a conventional one, so pointing a whole suite at a .env fixture needs
no change to the manifest:
Reads and writes use the same coordinates. secretspec set and interactive
check write to the referenced secret in place wherever the store supports
writes:
Terminal window
$secretspecsetDATABASE_URL
# writes the `password` field of the 1Password item `db`, in place
1Password edits the field with op item edit and does not create items.
Keyring, pass, dotenv, Bitwarden, Proton Pass, and LastPass write their refs as
well. Vault, AWS Secrets Manager, and Google Secret Manager are read-only for
refs and report that directly, rather than claiming the provider cannot write at
all.
A ref also composes with generate. If the referenced secret does not exist
yet, SecretSpec generates the value and writes it to the coordinates, so the
first check populates the item everything else already reads.
check, run, and the SDKs now group secrets by store and fetch the groups
concurrently instead of one store after another. Within a group, referenced
secrets use the store’s bulk API where it has one (AWS BatchGetSecretValue, and
the single Bitwarden, Proton Pass, and 1Password listings) and otherwise resolve
concurrently, fetching each unique coordinate once. CLI authentication for
1Password, LastPass, and Proton Pass is probed once per account or session
instead of once per provider instance.
A onepassword:// URI carrying an item path used to drop the path and target a
vault literally named vault. Item paths, including pasted
op://vault/item/field strings, now fail with an error that gives the ref
table to write instead. Provider URIs are store addresses only.
ref is always a table. String and URI forms are rejected, with the same
translation in the error.
Manifest validation now runs on every load. Rules that secretspec.toml
documents (a required secret cannot have a default, generate needs a
type, ref coordinates must be non-empty) are enforced on load rather than
ignored. A manifest that violated one of them will now fail with a clear error.