secretspec.toml Reference
secretspec.toml Reference
Section titled “secretspec.toml Reference”The secretspec.toml file defines project-specific secret requirements. This file should be checked into version control.
[project] Section
Section titled “[project] Section”[project]name = "my-app" # Project name (required)revision = "1.0" # Format version (required, must be "1.0")extends = ["../shared"] # Paths to parent configs for inheritance (optional)require_reason = "agents" # When to require a reason for secret access (optional)| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Project identifier |
revision | string | Yes | Format version (must be “1.0”) |
extends | array[string] | No | Paths to parent configuration files |
require_reason | "agents" | boolean | No | When secret access must supply a reason (via --reason, SECRETSPEC_REASON, or the SDK’s with_reason()). Defaults to "agents". |
The 1.0 revision is backward compatible: newer SecretSpec versions continue
to support existing revision = "1.0" configurations, although they may add
features to the revision before SecretSpec 1.0 is released. With the SecretSpec
1.0 release, revision 1.0 will be finalized. Later configuration format
changes may be introduced under new revision numbers.
Requiring a reason for secret access
Section titled “Requiring a reason for secret access”require_reason controls when secretspec demands a reason for accessing secrets.
It accepts three values:
| Value | Behavior |
|---|---|
"agents" (default) | Require a reason only when SecretSpec heuristically classifies the current process as an AI agent. Sessions not classified as agents are unaffected. |
true | Require a reason from every caller using SecretSpec (humans, CI, and agents). |
false | Never require a reason. |
The policy is enforced at SecretSpec’s secret-access entry points and travels
with the checked-in secretspec.toml. With true, every caller using the
manifest must supply a reason before SecretSpec proceeds:
# In a session SecretSpec detects as an agent, with the default "agents" policy:$ secretspec run -- ./deploy.shError: Accessing secrets requires a reason. Provide one with --reason "<why...>" ...
$ secretspec run --reason "Deploy web frontend" -- ./deploy.sh # okAgent detection. secretspec delegates heuristic detection of known agents to the
detect-coding-agent crate, which
maintains the per-tool signal list (Claude Code, Cursor, Codex, Gemini CLI,
Copilot, and more). It treats autonomous and hybrid environments as agents but
not human-driven interactive editors. In addition, secretspec checks its own
SECRETSPEC_AGENT environment variable as an explicit opt-in:
# Mark any harness the detector does not recognize as an agent:$ export SECRETSPEC_AGENT=1Cooperative harnesses that are not auto-detected can set SECRETSPEC_AGENT=1.
Do not rely on a caller to identify itself when a reason is mandatory; use
require_reason = true instead.
The reason is recorded in secretspec’s own audit log and is also forwarded to providers that support auditing (e.g. the Proton Pass provider records it in the agent audit log).
[profiles.*] Section
Section titled “[profiles.*] Section”Defines secret variables for different environments. At least one profile is
required. A default profile is optional; when present, other profiles inherit
from it unless they opt out in SecretSpec 0.19+.
[profiles.default] # Optional shared base profileDATABASE_URL = { description = "PostgreSQL connection", required = true }API_KEY = { description = "External API key", required = true }REDIS_URL = { description = "Redis cache", required = false, default = "redis://localhost:6379" }
[profiles.production] # Additional profile (optional)DATABASE_URL = { required = true } # description inherited from defaultProfile defaults
Section titled “Profile defaults”[profiles.<name>.defaults] supplies settings for secrets declared in that
profile:
| Field | Type | Required | Description |
|---|---|---|---|
inherit (0.19+) | boolean | No | For a non-default profile, whether to inherit declarations and omitted fields from [profiles.default] (default: true) |
required | boolean | No | Default requiredness for secrets declared in this profile |
default | string | No | Default value for secrets declared in this profile |
providers | array[string] | No | Default provider chain for secrets declared in this profile |
In SecretSpec 0.19+, set inherit = false for a standalone profile:
[profiles.deployment.defaults]inherit = false
[profiles.deployment]DEPLOY_TOKEN = { description = "Deployment credential", required = true }This excludes every [profiles.default] declaration and prevents explicitly
redeclared secrets from inheriting omitted fields. The setting has no effect on
the default profile itself. A standalone profile must declare at least one
secret.
Cross-secret presence constraints
Section titled “Cross-secret presence constraints”A profile can require alternative credentials by assigning secrets to a named group:
[profiles.default]PASSWORD = { description = "Account password", required = { at_least_one = "account_auth" } }ACCESS_TOKEN = { description = "Personal access token", required = { at_least_one = "account_auth" } }
GITHUB_TOKEN = { description = "GitHub token", required = { exactly_one = "github_auth" } }GITHUB_APP_KEY = { description = "GitHub App private key", required = { exactly_one = "github_auth" } }at_least_one requires one or more group members to resolve; exactly_one
requires one. Each field also accepts an array of group names for overlapping
groups. Groups must contain at least two secrets and cannot mix modes. Group
members are individually optional.
Under a scope, a group is judged over the members that scope exposes, so a scoped consumer never inherits a guarantee that rests on a secret it cannot see.
Secret Variable Options
Section titled “Secret Variable Options”Each secret variable is defined as a table with the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
description | string | Yes (see notes) | Human-readable description of the secret |
required | boolean or table | No | Whether absence is an error; the table form (0.17+) accepts at_least_one/exactly_one presence groups (defaults to true; false with default or a presence group) |
default | string | No | Default value if not provided |
composed (0.16+) | string | No | Derive a read-only value from other declared secrets using ${UPPERCASE_NAME} references |
providers | array[string] | No | List of provider aliases to use in fallback order |
ref | table | No | Coordinates naming an externally managed secret in the provider’s store (e.g. ref = { item = "db", field = "password" }) |
refs (0.19+) | table | No | Provider-alias-scoped coordinates, keyed by leaf alias (e.g. refs = { source = { item = "old" }, target = { item = "new" } }); mutually exclusive with ref |
as_path | boolean | No | Write secret to temp file and return file path (default: false) |
encoding (0.19+) | "base64", "base64url", or "hex" | No | Encode logical values before storage writes and decode stored values after reads |
extract (0.19+) | table | No | Select one field from stored JSON/INI text, or from the text of the secret named by from (0.21+) |
type | string | No | Secret type for generation: password, hex, base64, uuid, command, rsa_private_key, passphrase (0.21+), mnemonic (0.21+), openpgp_private_key (0.21+), ssh_private_key (0.21+), wireguard_private_key (0.21+), jwk_private_key (0.21+), age_identity (0.21+), x509_identity (0.21+). Typed conversion targets (0.21+): pkcs12, pkcs8_private_key, x509_certificate, x509_certificate_chain, x509_issuer_chain |
format (0.21+) | "pem" or "der" | No | Serialization of a pkcs8_private_key or x509_certificate; defaults to pem |
from (0.21+) | string | No | Declared secret this value is converted from (with a typed conversion target) or selected from (with extract) |
credentials (0.21+) | table | No | Declared secrets bound to the roles a type defines, e.g. credentials = { password = "PFX_PASSWORD" } for x509_identity and pkcs12 |
generate | boolean or table | No | Enable auto-generation when secret is missing |
prompt (0.19+) | boolean | No | Securely prompt for a missing value during secretspec run; the selected provider controls persistence |
Field notes:
descriptionis required on the effective secret. An inheriting profile may omit it when a matching default declaration supplies it. A standalone profile usinginherit = false(0.19+) must supply its own description.requireddefaults to false whendefaultis provided. In 0.17+, its table form acceptsat_least_oneandexactly_oneas a group name or array of names.defaultis invalid with an explicitrequired = true. A defaulted secret is guaranteed to be present in successful resolution and generated types, even though the provider does not have to supply it.typeis required whengenerateis enabled.generateanddefaultcannot both be set.prompt = true(0.19+) is for individually required secrets and cannot be combined withdefault, enabledgenerate,extract, orcomposed.extract(0.19+) is read-only and cannot be combined with enabledgenerate.from(0.21+) makes a secret read-only and derived: it needs eitherextractor a typed conversion target as itstype, and cannot declaredefault,providers,ref,refs, enabledgenerate,prompt = true, orcomposed. See Derived typed secrets.format(0.21+) is valid only for types with more than one serialization (pkcs8_private_keyandx509_certificate).credentials(0.21+) is valid only for types that define roles:x509_identityandpkcs12acceptpassword. Each value names a declared text secret. The{ provider, ref }table form is reserved.type = "x509_identity"(0.21+) is a binary type: it cannot be combined withdefaultorprompt = true, andencodingdefaults tobase64.
Composed Secrets
Section titled “Composed Secrets”A composed secret derives a value from other secrets in the effective profile. See Composed Secrets for the dependency model, CLI behavior, profile inheritance, and the differences from dotenv expansion:
[profiles.default]DB_USER = { description = "Database user" }DB_PASSWORD = { description = "Database password" }DB_HOST = { description = "Database host" }DATABASE_URL = { description = "PostgreSQL DSN", composed = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}/app" }References form a static dependency graph. Declaration order does not matter,
and composed secrets may reference other composed secrets. SecretSpec rejects
unknown references, cycles, malformed references, and source conflicts while
loading the manifest. A composed secret is read-only and cannot also set
default, providers, ref, refs (0.19+), type, enabled generate,
encoding (0.19+), extract (0.19+), format (0.21+), from (0.21+), or
credentials (0.21+). A composed template may reference a derived secret, for
example to embed the path of a certificate file.
Composition intentionally does not implement dotenv or shell expansion:
- only
${UPPERCASE_NAME}is a reference, and the name must match[A-Z][A-Z0-9_]*and identify a declared secret; - ambient environment variables are never consulted;
- fallback operators such as
${NAME:-fallback}, commands, and recursive expansion are unsupported; - inserted values are opaque and are never scanned again;
$$produces a literal$($${NAME}renders${NAME}), while ordinary braces are literal;- a missing dependency makes a required composition missing, while a
required = falsecomposition is omitted; - empty values remain empty and are distinct from missing values.
If a dependency uses as_path = true, its exported temporary-file path is the
text inserted into the composed value. Applying as_path = true to the
composed secret materializes the final combined value.
Composition is raw string concatenation. SecretSpec cannot know whether a
component occupies a URL username, password, host, path, query, or structured
document position, so it does not URL-encode or JSON-encode components. Store
components in the form required by the target format; use
secretspec export --format json when exporting the resolved secret map as
JSON.
[scopes] Section
Section titled “[scopes] Section”See Scopes for the conceptual model and a focused guide to narrowing services and tasks. This section specifies the complete configuration and resolution behavior.
Scopes name membership-only subsets of a profile’s secrets, so a single service
or task resolves only what it declares instead of the entire profile. They are
orthogonal to profiles: a profile decides how each secret resolves
(required, default, providers, references, generation, prompts (0.19+), as_path,
encoding (0.19+), extract (0.19+), from derivation (0.21+), and the
storage namespace); a scope only
decides which secrets take part in a given resolution.
[profiles.default]DATABASE_URL = { description = "Database", required = true }API_KEY = { description = "API key", required = true }QUEUE_TOKEN = { description = "Queue token", required = true }
[scopes.api]secrets = ["DATABASE_URL", "API_KEY"]
[scopes.worker]secrets = ["DATABASE_URL", "QUEUE_TOKEN"]$ secretspec run --scope api -- ./api # sees DATABASE_URL, API_KEY
$ secretspec run --scope worker -- ./worker # sees DATABASE_URL, QUEUE_TOKEN
$ secretspec check --scope api
$ secretspec export --scope worker --format dotenvBehavior:
- No scope resolves the complete profile, exactly as before scopes existed.
- Selecting a scope resolves the intersection of the merged profile and the
scope’s
secretslist — the visible set. A secret the profile does not declare is simply absent from that resolution rather than an error, so a scope can be reused across profiles that declare different subsets. - A required secret excluded by the active scope does not block resolution — it is not part of the scoped set.
- Composed secrets resolve their inputs without exposing them. When a visible
composed secret references secrets the scope
leaves out (for example
DATABASE_URLbuilt fromDB_USERandDB_PASSWORD), those dependencies are fetched to build the composition and then dropped from the output — the child seesDATABASE_URL, neverDB_USER/DB_PASSWORD. A secret that is neither visible nor a dependency of a visible secret is never fetched, so no provider is contacted for it. - A scope does not change a secret’s storage address
(
{project}/{profile}/{key}); it only narrows the set. - Presence groups are judged over the visible members. A
required = { at_least_one = … }or{ exactly_one = … }group (see Cross-secret presence constraints) is evaluated against the members the scope actually exposes. A group with no visible member is not that consumer’s concern and is not enforced. A group with some visible members is enforced over those alone, so a scope never inherits a guarantee that rests on a secret it hides — ifat_least_one = "cloud"is satisfied profile-wide byGCP_KEY, a scope showing onlyAWS_KEYstill fails whenAWS_KEYis absent.exactly_oneremains enforced whenever two visible members are both present: scoping narrows what is judged, never whether it is judged. A secret fetched only as a hidden composition input does not count as present, and a violation message names only visible members. The reverse case cannot be detected, because a secret the scope hides is never fetched: ifexactly_one = "token"is violated profile-wide by bothPRIMARYandFALLBACKbeing present, a scope showing onlyPRIMARYreports success. A scoped check validates the scoped consumer, not the profile; run an unscopedsecretspec checkto validate the profile as a whole. run --scoperemoves every manifest-declared secret the scope does not admit from the launched command’s environment, across all profiles rather than only the selected one, even if the parent shell already exported them, so a value inherited from another profile cannot leak into the child. Membership decides this, so a secret the scope lists survives even when the selected profile does not declare it (see the admitted rule below). This is secret minimization, not an authorization boundary: a process that still holds provider credentials could resolve another scope itself.export --scopeemits the visible set but unsets nothing, since its output formats have no way to express an unset. Narrowing an environment that already holds a wider set therefore needsrun --scope: aftereval "$(secretspec export)", a latereval "$(secretspec export --scope api)"leaves the previously exported values live in the shell.- An empty scope (or a scope whose intersection with the profile is empty) resolves to nothing and contacts no provider.
- Diagnostics do not name what the scope hides. A provider warning about a
hidden composition input calls it
a hidden composition inputrather than naming it, matching the way prompting is filtered, so a failing provider cannot disclose the very name the output filter removed. A visible secret is still named. This covers secretspec’s own messages; a provider’s error text is written by that provider and may still mention the address it searched. - Audit records what was read, not what was exposed: a
scoped
checklogs the accessed set, including a composition input the scope hides, since the point of the log is to capture provider access. Arunevent logs what it injected — the visible set. Scopedcheck,run, andexportevents also carry the selectedscopename (SecretSpec 0.17+). - An
as_pathsecret’s resolved value is its temp-file path, so a visible composition built from a hiddenas_pathinput embeds that path. The file stays alive for the duration of the command rather than being cleaned up with the hidden secret, so the path resolves. The hidden input is still absent from the environment; only its content, in the form the composition derived, is reachable — the same contract as a composed DSN that embeds a password. - A secret the scope admits is never scrubbed from
run, whether it fails to resolve (an optional secret with no stored value) or the selected profile does not declare it at all. A value the parent exported is inherited exactly as it would be without a scope; scoping changes which secrets are in play, never the semantics of one it admits. This is what lets a single scope be reused across profiles that declare different subsets. - Under project
extends, a child[scopes.<name>]replaces the parent scope of the same name outright — the twosecretslists are not unioned (see Configuration Inheritance). - Selecting an undefined scope, or a scope that lists a secret no profile declares, is a configuration error.
- A scope’s
secretslist must name at least one secret, with no blank or repeated entries. An empty scope is rejected rather than treated as “resolves to nothing”: it would contact no provider, socheck --scopewould report a clean0 found, 0 missingwhilerun --scopestarted the command with every manifest secret scrubbed and none injected. An empty intersection between a valid scope and the selected profile is still fine, since a scope is meant to be reused across profiles that declare different subsets.
The --scope flag (and the SECRETSPEC_SCOPE environment variable) apply to
check, run, and export. Scopes are a resolution-time feature of these
untyped paths. The write and copy commands are unaffected: set and import
ignore an ambient SECRETSPEC_SCOPE entirely, so a scope neither restricts what
they may write nor narrows the secrets they list. The untyped language SDK
builders also accept an explicit scope and return its name in resolve/report
results, and they honor SECRETSPEC_SCOPE when given none. The typed SDK
loaders generated by secretspec-derive always resolve the full profile and
deliberately ignore an ambient SECRETSPEC_SCOPE, since a generated struct
expects every declared field.
A blank --scope clears an inherited scope rather than being ignored:
SECRETSPEC_SCOPE=api secretspec run --scope "" -- ./job resolves the whole
profile and scrubs nothing. A blank SECRETSPEC_SCOPE with no flag means the
same, so a CI template that materializes an unset variable as an empty string
cannot silently narrow a job.
Complete Example
Section titled “Complete Example”[project]name = "web-api"revision = "1.0"extends = ["../shared/secretspec.toml"] # Optional inheritance
# Provider aliases used by profile provider chains[providers]prod_vault = "onepassword://Production"shared_vault = "onepassword://Shared"keyring = "keyring://"env = "env://"
# Default profile - always loaded first[profiles.default]APP_NAME = { description = "Application name", required = false, default = "MyApp" }SESSION_SECRET = { description = "Session signing secret", required = true, providers = ["shared_vault"] }GITHUB_TOKEN = { description = "GitHub token", required = true, providers = ["env"] }
# Development profile - extends default[profiles.development]DATABASE_URL = { description = "Database connection", required = false, default = "sqlite://./dev.db" }API_URL = { description = "API endpoint", required = false, default = "http://localhost:3000" }DEBUG = { description = "Debug mode", required = false, default = "true" }
# Production profile - extends default[profiles.production]DATABASE_URL = { description = "PostgreSQL cluster connection", required = true, providers = ["prod_vault", "keyring"] }API_URL = { description = "Production API endpoint", required = true }SENTRY_DSN = { description = "Error tracking service", required = true, providers = ["shared_vault"] }REDIS_URL = { description = "Redis cache connection", required = true }Provider Aliases
Section titled “Provider Aliases”Provider aliases may be declared in two places:
- In
secretspec.toml— a top-level[providers]table. Check this into version control so every team member and CI runner sees the same mapping out of the box. - In
~/.config/secretspec/config.toml— a per-user[defaults.providers]table for personal overrides.
On conflict the project-level alias wins, so a stale local config cannot silently shadow the team’s mapping.
[providers]prod_vault = "onepassword://Production"shared_vault = "onepassword://Shared"keyring = "keyring://"env = "env://"
[profiles.production]DATABASE_URL = { description = "Production DB", providers = ["prod_vault", "keyring"] }[defaults]provider = "keyring"
[defaults.providers]prod_vault = "onepassword://Production"shared_vault = "onepassword://Shared"keyring = "keyring://"env = "env://"Manage user-level aliases via CLI:
# SecretSpec 0.17+: add a provider alias to your user config$ secretspec config global provider add prod_vault "onepassword://Production"
# SecretSpec 0.17+: list all aliases known to your user config$ secretspec config global provider list
# SecretSpec 0.17+: remove an alias from your user config$ secretspec config global provider remove prod_vaultThese explicitly scoped CLI commands operate on the user-global config only —
edit secretspec.toml by hand to change project-level aliases.
Credential-aware alias values
Section titled “Credential-aware alias values”In SecretSpec 0.15 and later, an alias value is either a bare provider URI
string or a table that also declares the credentials the provider needs. Both
forms are accepted in the project [providers] and user
[defaults.providers] tables.
| Field | Type | Required | Description |
|---|---|---|---|
uri | string | Yes (table form) | The provider URI. A bare-string alias is shorthand for { uri = "..." }. |
credentials | table | No | Maps a semantic provider credential name to its source. |
ref (0.19+) | table | No | Native-address template for this leaf alias. Coordinate strings may contain {project}, {profile}, and {key}. |
Each credentials value is either a bare provider spec — read at the convention path for the active project and profile — or a table { provider = "...", ref = { ... } } that pins the exact location with the same ref coordinates a secret uses.
[providers]keyring = "keyring://"# bare string: read access_token from keyring at the convention pathbws = { uri = "bws://project-uuid", credentials = { access_token = "keyring" } }
[providers.vault_prod]uri = "vault://secret/myapp?auth=approle"credentials = { role_id = { provider = "onepassword", ref = { vault = "Infra", item = "vault-approle", field = "role_id" } }, secret_id = { provider = "onepassword", ref = { vault = "Infra", item = "vault-approle", field = "secret_id" } } }Configured credentials take precedence over provider environment fallbacks, credential chains are limited to one hop, and a fetched credential is never written to the environment. Store the credentials with secretspec config provider login. See Provider credentials for the full behavior.
Starting with SecretSpec 0.19, a leaf alias may also compile logical secret names into that provider’s native coordinates. Templates expand each placeholder once; text inserted from a project, profile, or key is never interpreted as another placeholder.
[providers]remote = { uri = "onepassword://Production", ref = { item = "{project}-{profile}", field = "{key}" } }local = { uri = "dotenv://.env", ref = { item = "{key}" } }
[profiles.production]API_KEY = { description = "API key", providers = ["remote", "local"] }Templates belong on the leaf aliases in a cached route, not on the cached
alias itself. Bare provider names and literal URIs have no alias identity, so
they use provider convention naming unless the secret declares legacy ref.
Inline provider cache
Section titled “Inline provider cache”Use uri and cache when one provider is authoritative. credentials remains
optional and configures that same provider:
| Field | Type | Required | Description |
|---|---|---|---|
uri | string | Yes | Authoritative provider URI. |
credentials | table | No | Provider-specific credential sources for uri. |
cache | table | Yes | Local cache policy containing provider and max_age. |
cache.provider | string | Yes | Leaf provider spec used to store cache entries. Must support deletion and address a different store from uri. |
cache.max_age | string | Yes | Positive duration with s, m, h, d, or w units, such as 30m, 8h, or 1d. |
[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 alias remains both the selected cached route and the build key for its authoritative provider, so its configured credentials apply normally.
Cached fallback alias values
Section titled “Cached fallback alias values”A cached fallback alias uses fallback and cache when more than one provider
can answer:
| Field | Type | Required | Description |
|---|---|---|---|
fallback | array[string] | Yes | Non-empty authoritative provider route. Reads try entries in order; writes use the first entry. |
cache | table | Yes | Local cache policy containing provider and max_age. |
cache.provider | string | Yes | Leaf provider spec used to store cache entries. Must support deletion (keyring, pass, gopass, dotenv, age (0.20+), Azure App Configuration (0.20+), or Vault/OpenBao KV v2) and be a different store from every fallback entry. |
cache.max_age | string | Yes | Positive duration with s, m, h, d, or w units, such as 30m, 8h, or 1d. |
[providers]azure = { uri = "akv://team-vault", credentials = { client_secret = "keyring" } }env = "env://"local = "keyring://secretspec/cache/{project}/{profile}/{key}"myprovider = { fallback = ["azure", "env"], cache = { provider = "local", max_age = "8h" } }
[profiles.development.defaults]providers = ["myprovider"]Every cached alias is a complete route and must be the only entry when selected
through providers, in any position. Fallback entries and the cache provider
accept aliases, provider names, and URIs, but must resolve to leaf providers;
cached aliases cannot be nested, and the cache must resolve to a different
store than the route’s own authoritative providers, since it holds its entries
at the same logical address. The cache provider must also be one SecretSpec can
delete from — keyring, pass, gopass, dotenv, age (0.20+), Azure App
Configuration (0.20+), or a Vault/OpenBao KV v2 mount — since every form of
invalidation is a delete. Put credentials on leaf aliases rather than the
cached fallback alias.
See Provider caching
for freshness, failure, invalidation, and clearing behavior.
Legacy bare-URI alias values
Section titled “Legacy bare-URI alias values”In SecretSpec 0.14, every alias value must be a provider URI string:
[providers]bws = "bws://project-uuid"For example, authenticate the 0.14 BWS provider by setting its environment variable before running SecretSpec:
$ export BWS_ACCESS_TOKEN="0.your-access-token..."
$ secretspec checkAudit Logging
Section titled “Audit Logging”secretspec records every secret access to a local audit log.
Auditing is a per-machine/operator concern — where the log lives and whether it is
on — so it is configured in the user-global config, not the project’s
secretspec.toml. A cloned repository therefore cannot redirect or silence your
audit log. Auditing is on by default; configure it under the top-level
[audit] table:
[audit]enabled = true # set false to turn auditing offpath = "~/.local/state/secretspec/audit.log" # default: per-user XDG state dirmax_size_bytes = 1048576 # default: 1 MiB| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Whether to record secret access. |
path | string | per-user state dir | Where to write the JSON Lines log. Must be absolute (a leading ~ is expanded); a relative path is rejected and auditing is disabled. |
max_size_bytes | integer | 1048576 (1 MiB) | Hard size cap. At the cap the file is truncated and restarted; no rotated backups are kept. |
Secret values are never written to the log, and credentials embedded in provider URIs are redacted. Audit failures never block secret access. See Audit Logging for the record format and full details.
as_path Option
Section titled “as_path Option”When as_path = true, the secret value is written to a temporary file and the file path is returned instead of the value:
[profiles.default]TLS_CERT = { description = "TLS certificate", as_path = true }GOOGLE_APPLICATION_CREDENTIALS = { description = "GCP service account", as_path = true }When combined with encoding (0.19+), the file contains the decoded bytes
rather than the stored textual representation. When combined with extract
(0.19+), it contains only the selected logical value.
| Context | Behavior |
|---|---|
CLI (get, check, run) | Files are persisted (not deleted after command exits) |
| Rust SDK | Files cleaned up when ValidatedSecrets is dropped; use keep_temp_files() to persist |
| Rust SDK types | PathBuf or Option<PathBuf> instead of String |
Secret Encoding
Section titled “Secret Encoding”encoding (0.19+) defines the textual representation stored by providers and
the cache. It is independent of as_path: decoded UTF-8 remains an ordinary
environment or SDK value, while arbitrary decoded bytes can be materialized to
a file.
[profiles.default]# encoding is available in SecretSpec 0.19+TEXT_CONFIG = { description = "Encoded text", encoding = "base64" }KEYSTORE = { description = "Binary mTLS keystore", encoding = "base64", as_path = true }URL_SAFE_KEY = { description = "URL-safe encoded key", encoding = "base64url", as_path = true }HEX_KEY = { description = "Hex-encoded key", encoding = "hex", as_path = true }| Encoding (0.19+) | Written representation | Accepted stored representation |
|---|---|---|
base64 | RFC 4648 standard Base64 with padding | Padded or unpadded standard Base64 |
base64url | RFC 4648 URL-safe Base64 without padding | Padded or unpadded URL-safe Base64 |
hex | Lowercase RFC 4648 Base16 | Uppercase, lowercase, or mixed-case Base16 |
Exactly one trailing LF or CRLF is accepted so command-captured values work
without preprocessing. Other whitespace and non-alphabet characters are
rejected. Without as_path = true, decoded bytes must be valid UTF-8.
secretspec set, interactive prompts, and ordinary generated secrets provide
logical text; SecretSpec encodes it before writing to a provider or cache.
Defaults and composed results are already logical and are not transformed. The
secretspec import command copies the stored representation verbatim, avoiding
double encoding.
Structured Extraction
Section titled “Structured Extraction”extract (0.19+) selects one logical secret from structured text read from a
provider or cache. It supports JSON (0.19+) and INI (0.20+). JSON pointer
values are RFC 6901 JSON Pointers:
[providers]documents = "file:./secrets"
[profiles.default]# extract is available in SecretSpec 0.19+DB_USER = { description = "Database user", providers = ["documents"], ref = { item = "application.json" }, extract = { format = "json", pointer = "/database/user" }}DB_PASSWORD = { description = "Database password", providers = ["documents"], ref = { item = "application.json" }, extract = { format = "json", pointer = "/database/password" }}Both declarations read the same document. /database/password walks nested
objects, /hosts/0 selects an array element, and /a~1b/~0key selects the key
~key beneath an a/b object. The empty pointer selects the complete document.
JSON strings become their unquoted contents. Numbers, booleans, and null use
their JSON spelling; objects and arrays become compact JSON. Invalid JSON or a
pointer that does not match is a decoding error. Once a provider returns a
document, extraction failure is not treated as a provider miss and does not
continue along a fallback chain.
INI extraction (0.20+) uses the same RFC 6901 escaping for pointer segments but
accepts only value selectors. /key selects an unsectioned key, while
/section/key selects a key in a named section:
[profiles.default]# format = "ini" requires SecretSpec 0.20+DB_PASSWORD = { description = "Database password", providers = ["documents"], ref = { item = "application.ini" }, extract = { format = "ini", pointer = "/database/password" }}For example, that pointer reads password from [database]. An explicit
[DEFAULT] section is selected as /DEFAULT/key; it is distinct from an
unsectioned /key. Section and key matching is case-sensitive. ~1 selects a
literal / and ~0 selects a literal ~, just as in JSON Pointer. INI values
always remain strings, and literal backslashes are preserved. Empty pointers,
pointers deeper than /section/key, malformed INI, and unmatched pointers are
decoding errors.
In 0.21+, from names another declared secret as the document to select from.
The source is resolved first and may itself come from a provider, default,
generator, or another derived declaration. from dependencies share composed
secrets’ static graph: declaration order does not matter, scoped resolutions
fetch hidden dependencies without exposing them, and unknown names or cycles
are rejected while loading the manifest. JSON and INI continue to use
pointer, and the source must be text (a binary typed source is rejected):
[profiles.default]# from requires SecretSpec 0.21+GENERATED_DOCUMENT = { description = "Generated JSON", type = "command", generate = { command = "generate-config" } }DATABASE_PASSWORD = { description = "Database password", from = "GENERATED_DOCUMENT", extract = { format = "json", pointer = "/database/password" } }Stored-value transforms run in this order:
provider or cache → encoding decode → structured extraction → as_pathfrom source → source resolution → structured extraction → as_pathThis makes a Base64-encoded JSON document valid input when a declaration sets
both encoding = "base64" (0.19+) and extract (0.19+). A provider-native
ref.field is also resolved first, so a field whose contents are JSON can be
selected further. Without from, defaults and composed values are already
logical and are not extracted.
Extracted secrets are read-only. set, delete, interactive prompting,
generation, and import reject them rather than replacing or removing the
containing document and its sibling values. Update the document through its
owning system instead. A from selection reports its source as the value to
change.
Derived typed secrets
Section titled “Derived typed secrets”A secret with from and a typed conversion target as its type converts
another declared secret. In 0.21 the source is always an x509_identity: a
PKCS#12 archive holding one private key, its leaf certificate, and an optional
issuer chain, whether generated by SecretSpec or imported from a provider. Every
target is an ordinary flat secret with its own name, so SDKs and run see it
like any other value.
| Type (0.21+) | Output | format | Binary |
|---|---|---|---|
pkcs12 | Complete PKCS#12/PFX archive, protected by the password credential when one is bound | fixed | yes |
pkcs8_private_key | Unencrypted PKCS#8 private key | pem (default) or der | der only |
x509_certificate | Leaf certificate | pem (default) or der | der only |
x509_certificate_chain | Leaf followed by its ordered issuer certificates | pem | no |
x509_issuer_chain | Ordered issuer certificates without the leaf | pem | no |
No target is itself an accepted source, so conversions are one level deep. A composed template may still reference a target.
Generated identity and password protected PFX
Section titled “Generated identity and password protected PFX”[profiles.development]# type = "x509_identity", from, format, and credentials require SecretSpec 0.21+TLS_IDENTITY = { description = "Canonical development TLS identity", type = "x509_identity", generate = { san = ["dns:localhost", "ip:127.0.0.1"], usages = ["server_auth"], valid_for = "30d" }}
TLS_PFX_PASSWORD = { description = "Password protecting the exported PFX", type = "passphrase", generate = true}
TLS_PFX = { description = "Password protected Windows TLS identity", type = "pkcs12", from = "TLS_IDENTITY", credentials = { password = "TLS_PFX_PASSWORD" }, as_path = true}
TLS_KEY = { description = "PKCS#8 private key", type = "pkcs8_private_key", from = "TLS_IDENTITY", as_path = true}
TLS_CERTIFICATE_DER = { description = "Leaf certificate as DER", type = "x509_certificate", format = "der", from = "TLS_IDENTITY", as_path = true}
TLS_CHAIN = { description = "Leaf followed by its issuer chain", type = "x509_certificate_chain", from = "TLS_IDENTITY"}TLS_IDENTITY does not spell encoding: binary types default to Base64. One
resolve generates or reads the identity and the password, then materializes
every target. The consumer receives TLS_PFX as a .pfx path and
TLS_PFX_PASSWORD as a value, which is what it needs to load the archive.
Importing a protected PFX
Section titled “Importing a protected PFX”The password that opens a stored archive belongs to the source declaration. Projections do not repeat it:
[profiles.production]SERVICE_IDENTITY = { description = "Imported service identity", type = "x509_identity", providers = ["identity_store"], credentials = { password = "SERVICE_IDENTITY_PASSWORD" }}
SERVICE_IDENTITY_PASSWORD = { description = "Password protecting the imported PFX", providers = ["onepassword"], ref = { vault = "Infrastructure", item = "Production TLS", field = "PFX password" }}
SERVICE_KEY = { description = "Service private key", type = "pkcs8_private_key", from = "SERVICE_IDENTITY", as_path = true }SERVICE_CERTIFICATE = { description = "Service certificate chain", type = "x509_certificate_chain", from = "SERVICE_IDENTITY", as_path = true }The password is an ordinary declared secret with provider routing, native
coordinates, secretspec set, import, audit, and scope handling. If it is
missing, the identity and every projection are reported missing under their own
required policy. A wrong password, or a protected archive with no password
bound, is a decoding error; SecretSpec never falls back to guessing.
To rewrap an archive under a new password, derive a pkcs12 from the imported
identity with a different credentials.password. Omitting the credential on a
pkcs12 target produces an empty-password archive, an interchange container
rather than a security boundary.
Delivery
Section titled “Delivery”as_path = truewrites the raw bytes to an owner-only file with the type’s suffix:.pfx,.pem, or.der.- Without
as_path, a binary target is exposed inline as text in itsencoding(base64by default;base64urlandhexare accepted). This suits CI systems that take a Base64 PFX in an environment variable. A text target is exposed as is, andencodingis rejected on it. - Stored identities are validated once: the archive must be between 1 byte and 10 MiB, hold one private key and one matching leaf, carry at most 16 issuer certificates that form one unambiguous leaf-to-root chain with verified signatures, and be within every validity period. Unordered certificate bags are put in order; unrelated, duplicate, or ambiguous bags are rejected.
- Generated
pkcs12output uses PBES2/PBKDF2 with HMAC-SHA-256, AES-256-CBC for key and certificate privacy, and HMAC-SHA-256 integrity, each with 100,000 iterations. RC2, 3DES, and SHA-1 are never emitted. - A bound password must be non-empty, contain no NUL, and be at most 1024 bytes; an empty value is an error rather than “no password”.
fromrequires eitherextract(selection) or a conversion targettype(conversion), never both, and cannot setdefault,providers,ref,refs, enabledgenerate,prompt = true, orcomposed.- The source’s type must be accepted by the target: every target derives from
x509_identity.x509_identityitself is stored or generated and cannot be derived. formatis accepted only bypkcs8_private_keyandx509_certificate.credentialsroles are defined per type:x509_identityandpkcs12acceptpassword; other targets accept none. A credential must name a declared text secret, so neither anas_pathsecret nor a binary typed secret qualifies. A generatedx509_identitycannot bind a password; it is stored with an empty password and the provider is its security boundary.fromandcredentialsadd edges to the same dependency graph ascomposed: unknown names, self references, and cycles are rejected when the manifest loads, and scopes fetch hidden sources and credentials without exposing them.- Derived secrets are read-only.
setanddeletename the source to change instead;importand generation skip them. - Profile inheritance merges
from,format, andcredentialsfield by field like every other field, then validates the merged secret. An override may clear inherited credentials withcredentials = {}. check --explainlabels a conversionconverted from <SOURCE>and a selectionselected from <SOURCE>; resolve payloads report both with the existingcomposedprovenance.
Secret References
Section titled “Secret References”The ref field names one externally managed secret by the store’s own
coordinates, instead of SecretSpec’s {project}/{profile}/{key} convention. See
Secret References for the concept, model, and examples;
this section is the specification.
[profiles.production]DATABASE_URL = { description = "Postgres DSN", ref = { item = "db", field = "password" }, providers = ["prod_vault"] }INFRA_TOKEN = { description = "Infra token", ref = { vault = "Production", item = "infra", field = "token" } }GITHUB_TOKEN = { description = "GitHub token", ref = { item = "GITHUB_PAT" }, providers = ["env"] }ref is a table of provider-independent coordinates. Unknown keys are rejected
at parse time. Only item is universal; it is the secret’s complete name in the
store and replaces the whole convention path, including any folder_prefix or
format string the provider is configured with (nothing is prepended). A
coordinate a store has no equivalent for is rejected with an error naming it,
never silently ignored.
| Coordinate | Required | Meaning |
|---|---|---|
item | Yes | The store’s complete name for the secret. Replaces the whole convention path |
field | No | A named component inside the item. Rejected by stores whose secrets hold a single value |
vault | No | The container holding the item. 1Password only; other stores take their container from the provider URI |
section | No | A named group of fields inside the item. 1Password only; requires field |
version | No | Which revision of the secret to read. Supported by versioned stores such as Google Secret Manager, AWS Parameter Store (0.18+), and Azure Key Vault (0.20+); defaults to the latest |
Stores fall into two groups for field:
| Store | Shape of one secret | field |
|---|---|---|
| dotenv, file (0.19+), env, pass, LastPass, Proton Pass, Bitwarden, AWS Parameter Store (0.18+) | a single value | Rejected: there is nothing to select |
| 1Password, Keeper (0.18+), Passbolt (0.19+), Vault KV, AWS Secrets Manager, keyring | a record of named parts | Selects the part: field label, map key, JSON key, account |
vault is the only container coordinate. For every store except 1Password the
container is part of the provider URI, not the ref:
# The mount `kv2` comes from the URI; the ref names the path inside it.DB = { description = "DB", ref = { item = "myapp/config", field = "pw" }, providers = ["vault://vault.example.com:8200/kv2"] }
# 1Password: `vault` on the ref overrides the URI's default vault.TOKEN = { description = "Token", ref = { vault = "Production", item = "infra", field = "token" }, providers = ["onepassword://Private"] }Which provider resolves a ref follows the ordinary provider resolution
order; a ref composes with the providers fallback
chain, and each provider is asked for the same coordinates.
Provider-scoped references
Section titled “Provider-scoped references”Use refs when one logical secret already has different native coordinates in
different providers. Keys are leaf provider aliases; they are identity, not a
URI lookup, so aliases that happen to resolve to the same URI remain distinct.
An entry may name an import-only source alias that is absent from the secret’s
ordinary providers route.
[providers]old = "onepassword://Legacy"new = { uri = "onepassword://Production", ref = { item = "{project}-{profile}", field = "{key}" } }local = "keyring://"
[profiles.production]API_KEY = { description = "API key", providers = ["new", "local"], refs = { old = { item = "legacy-api", field = "token" } } }For each selected endpoint, address resolution is:
- Legacy route-wide
ref, when present (for compatibility). - The matching
refs.<alias>entry. - The matching alias’s
reftemplate. - The provider’s ordinary
{project}/{profile}/{key}convention.
ref and refs cannot be combined on one effective secret. Every refs key
must name a defined leaf alias; cached route aliases cannot own templates or be
used as scoped-ref keys. A literal URI or bare provider name has no alias key,
so only legacy ref or convention naming applies to it.
During profile inheritance, ref and refs (0.19+) form one setting rather
than two independently inherited fields. The most specific profile entry that
declares either form supplies the whole setting: an explicit refs replaces an
inherited ref, and an explicit ref replaces inherited refs. If the profile
entry declares neither, it inherits whichever form [profiles.default] uses.
How providers interpret the coordinates
Section titled “How providers interpret the coordinates”| Provider | item | field | Without field | Writes via ref |
|---|---|---|---|---|
| 1Password | Item title or UUID | Field label; vault and section also apply | Reads the item like a convention secret (its value or password field); writes edit the value field | ✅ via op item edit (adds a missing field, never creates items) |
| Keeper (0.18+) | Record UID or exact title | Standard field type/label or custom field label | Reads password | ✅ for existing records and fields |
| keyring | Service | Account (defaults to the current system username) | Current user’s entry | ✅ |
| dotenv | .env key | Rejected | Reads the key | ✅ |
| file (0.19+) | Relative file path beneath the configured root | Rejected | Reads the complete UTF-8 file | ✅ |
| env | Variable name | Rejected | Reads the variable | — (read-only) |
| EJSON (0.20+) | RFC 6901 JSON Pointer | Rejected | Reads the selected JSON string | — (read-only) |
| systemd credentials (0.17+) | Credential filename | Rejected | Reads the credential | — (read-only) |
| Fly.io secrets (0.20+) | Fly app secret name | Rejected | Error: Fly.io does not expose plaintext values | ✅ write-only via flyctl secrets set |
| Cloudflare Secrets Store (0.20+) | Account-secret name in the selected store | Rejected | Error: Cloudflare’s management API does not expose plaintext values | ✅ write-only via the Cloudflare API |
| pass | Entry path | Rejected | Reads the entry | ✅ |
| Gopass (0.15+) | Entry path, including any mount-point prefix | Rejected | Reads the entry | ✅ |
| LastPass | Item name | Rejected | Reads the item | ✅ |
| Dashlane (0.18+) | Item title or identifier | Field name on the item | Reads the type’s default field (content, or password for a login) | — (read-only) |
| Proton Pass | Item title | Rejected | Reads the note | ✅ |
| Passbolt (0.19+) | Resource UUID or exact name | password, username, uri, or description | Reads password | ✅ for existing resources; never creates through ref |
| Vault | KV path relative to the mount | Required (KV entries are maps) | Error | — (read-only) |
| OpenBao (0.17+) | KV path relative to the mount | Required (KV entries are maps) | Error | — (read-only) |
| AWS Secrets Manager | Secret name or ARN | JSON key | Whole secret string | — (read-only) |
| AWS Parameter Store (0.18+) | Parameter name or ARN; version selects a version or label | Rejected | Reads the decrypted value | ✅ by unversioned parameter name; version, label, and ARN refs are read-only |
| GCSM | Secret id; version also applies | Rejected | Reads latest or the pinned version | — (read-only) |
| Bitwarden (bws) | BWS key name | Rejected | Reads the key | ✅ |
| Azure Key Vault (0.15+) | Secret name; version pins a version (0.20+) | Rejected | Reads latest or the pinned version (0.20+) | — (read-only) |
| Azure App Configuration (0.20+) | App Configuration key | Rejected | Reads the direct value or resolves its canonical Key Vault reference | — (read-only) |
| Infisical (0.16+) | Folder and key; version also applies | Rejected | Reads the latest version | ✅ unless a version is pinned |
| Kubernetes (0.20+) | Secret key | Rejected | Reads entry | ✅ |
A provider rejects coordinates it has no equivalent for, with an error naming
the coordinate (for example, field on the env provider).
Writing through a ref
Section titled “Writing through a ref”Writes are symmetric with reads: secretspec set and interactive check
prompting write through the coordinates in place wherever the table above says
writes are supported. Read-only stores fail with a clear error instead.
No string refs
Section titled “No string refs”ref is always a table. String and URI forms (ref = "op://vault/item/field",
ref = "env://VAR", query-parameter URIs, and similar) are rejected, and the
error spells out the exact table translation. For example, a pasted 1Password
reference op://Production/infra/token translates to:
INFRA_TOKEN = { description = "Infra token", ref = { vault = "Production", item = "infra", field = "token" }, providers = ["onepassword://Production"] }Provider URIs stay store addresses only: onepassword://Production names a
vault, and item paths on provider URIs are errors.
Deduplication, auditing, and reporting
Section titled “Deduplication, auditing, and reporting”- Secrets sharing identical coordinates and store are fetched once.
- Audit log events carry a
reffield with the coordinates. check --explainandcheck --jsonattribute ref secrets to the store URI they resolved from.
Prompt on missing during run
Section titled “Prompt on missing during run”Use prompt = true when secretspec run should ask the operator after every
configured provider has returned missing. Prompting is the value source;
persistence remains a property of the selected provider.
With a writable provider, the answer is saved and reused by later runs. The
write destination and writability are checked before the hidden prompt opens,
just as they are for secretspec set. Use the null provider when the answer
must exist only for one child invocation:
[profiles.default]DEPLOY_PASSWORD = { description = "One-time deployment password", required = true, prompt = true, providers = ["null"] }Here null makes the operator the only possible value source and explicitly
declines persistence, so the answer is injected into the child environment and
discarded after it exits. It is not written to a provider or cache. The prompt
uses the controlling terminal rather than the command’s stdin, so a pipe or
redirected file remains available to the child:
$ printf 'deployment input\n' | secretspec run -- ./deploy? Enter value for DEPLOY_PASSWORD (profile: default):Only run interprets prompt = true as a missing-value policy. get, export,
SDK resolution, and value-free reports do not prompt. Interactive check
retains its existing setup behavior instead: it offers to store any missing
required secret, independently of prompt, and therefore cannot satisfy a
null-backed declaration. A run without a controlling terminal fails before
starting the child. Explicit set and import operations remain governed by the
provider, not by prompt.
prompt = true is limited to individually required secrets and cannot be
combined with default, enabled generate, extract, or composed. Profile
overrides may set prompt = false to return to ordinary missing-value behavior.
Secret Generation
Section titled “Secret Generation”When type and generate are set, missing secrets are automatically generated during check or run and stored via the configured provider:
[profiles.default]# Simple: generate with type defaultsDB_PASSWORD = { description = "Database password", type = "password", generate = true }REQUEST_ID = { description = "Request ID prefix", type = "uuid", generate = true }
# Custom optionsAPI_TOKEN = { description = "API token", type = "hex", generate = { bytes = 32 } }SESSION_KEY = { description = "Session key", type = "base64", generate = { bytes = 64 } }
# Shell commandMONGO_KEY = { description = "MongoDB keyfile", type = "command", generate = { command = "openssl rand -base64 765" } }
# RSA private key (PKCS1 PEM)JWT_SIGNING_KEY = { description = "JWT signing key", type = "rsa_private_key", generate = true }
# OpenPGP signing key (0.21+)RELEASE_KEY = { description = "Release signing key", type = "openpgp_private_key", generate = { user_id = "Release Bot <releases@example.com>", capabilities = ["sign"] } }
# OpenSSH Ed25519 private key (0.21+)DEPLOY_KEY = { description = "Deployment SSH key", type = "ssh_private_key", generate = true }
# Private P-256 JSON Web Key (0.21+)JWT_JWK = { description = "JWT signing JWK", type = "jwk_private_key", generate = { algorithm = "p256", kid = "release-2026" } }
# Native age X25519 identity (0.21+)BACKUP_IDENTITY = { description = "Backup encryption identity", type = "age_identity", generate = true }
# Self-signed P-256 X.509 identity stored as a Base64 PKCS#12 archive (0.21+)LOCAL_TLS = { description = "Local TLS identity", type = "x509_identity", generate = { san = ["dns:localhost", "ip:127.0.0.1"] } }
# Type without generate: informational only, no auto-generationMANUAL_SECRET = { description = "Manually managed", type = "password" }Generation Types
Section titled “Generation Types”| Type | Default Output | Options |
|---|---|---|
password | 32 alphanumeric chars | length (int), charset ("alphanumeric" or "ascii") |
passphrase (0.21+) | Seven BIP-39 English words joined with - | words (6–32), separator (non-empty string) |
mnemonic (0.21+) | 24-word English BIP-39 mnemonic | algorithm ("bip39"), words (12, 15, 18, 21, or 24), language ("english") |
hex | 64 hex chars (32 bytes) | bytes (int) |
base64 | 44 chars (32 bytes) | bytes (int) |
uuid | UUID v4 (36 chars) | none |
command | stdout of command | command (string, required) |
rsa_private_key | 2048-bit RSA private key (PKCS1 PEM) | bits (int) |
openpgp_private_key (0.21+) | ASCII-armored OpenPGP v4 transferable secret key | user_id (required), algorithm ("ed25519" or "rsa"), bits (RSA only), capabilities (["sign"], ["encrypt"], or both) |
ssh_private_key (0.21+) | Unencrypted OpenSSH Ed25519 private key | algorithm ("ed25519" or "rsa"), bits (RSA only), comment (string) |
wireguard_private_key (0.21+) | Base64-encoded WireGuard private key | none |
jwk_private_key (0.21+) | Ed25519 private signing JWK | algorithm ("ed25519", "p256", or "rsa"), bits (RSA only), kid (string) |
age_identity (0.21+) | Native X25519 age identity | none |
x509_identity (0.21+) | PKCS#12 archive (Base64 at rest) containing a P-256 key and self-signed certificate | issuer ("self_signed"), algorithm ("p256"), san (required DNS/IP names), usages ("server_auth" and/or "client_auth"), valid_for (1–200 days) |
OpenPGP private-key generation
Section titled “OpenPGP private-key generation”openpgp_private_key is generated entirely in Rust and does not invoke GnuPG.
The default algorithm = "ed25519" creates an Ed25519 certification-only
primary key plus separate Ed25519 signing and/or Curve25519 encryption subkeys.
For compatibility with RSA-only consumers, algorithm = "rsa" uses RSA for
the primary key and all requested subkeys. RSA defaults to 3072 bits;
bits accepts 2048 through 8192 and is invalid with "ed25519".
Omitting capabilities selects both; the list must otherwise contain "sign",
"encrypt", or both without duplicates. generate = true is invalid because
every generated certificate requires an explicit user_id.
The ASCII-armored transferable secret key has no OpenPGP passphrase and no expiration. Store it with an encrypted provider when it needs protection at rest. Its public certificate and fingerprint can be derived after import by OpenPGP tooling; SecretSpec stores the secret key as one logical value.
SSH private-key generation
Section titled “SSH private-key generation”ssh_private_key is generated entirely in Rust. generate = true creates an
unencrypted Ed25519 OpenSSH private key. Select algorithm = "rsa" for
compatibility; RSA defaults to 3072 bits and accepts 2048 through 8192. bits
is invalid with Ed25519. An optional comment is embedded in the key and must
not contain control characters.
Additional credential generation
Section titled “Additional credential generation”passphraseindependently selects seven BIP-39 English words by default (77 bits of entropy) and joins them with-. It is not a BIP-39 mnemonic.wordsaccepts 6 through 32;separatormust be non-empty and contain no control characters.mnemonic(0.21+) emits a checksum-valid BIP-39 mnemonic. It defaults to 24 English words (256 bits of entropy plus checksum);wordsaccepts 12, 15, 18, 21, or 24. The extensible subtype fields currently accept onlyalgorithm = "bip39"andlanguage = "english". SecretSpec returns the mnemonic itself and does not derive a BIP-32 seed, wallet keys, or BIP-39’s optional mnemonic passphrase.wireguard_private_keyemits the standard Base64-encoded, clamped 32-byte scalar accepted by WireGuard and accepts no options.jwk_private_keyemits a compact private signing JWK with public parameters,use = "sig", andkey_ops = ["sign"]. It defaults to Ed25519 with RFC 9864’s fully specifiedEd25519JOSE algorithm identifier;algorithm = "p256"selects P-256/ES256, and"rsa"selects RSA/RS256. RSA defaults to 3072 bits and accepts 2048 through 8192.kidadds optional key-identification metadata.age_identityemits a native X25519AGE-SECRET-KEY-1...identity and accepts no options. This is the interoperable classic format supported by the Rust age provider. For post-quantum confidentiality, provision an externally generated hybrid ML-KEM-768+X25519AGE-SECRET-KEY-PQ-1...identity until the Rust age library supports that format.
X.509 identity generation
Section titled “X.509 identity generation”x509_identity generates a P-256 key and a self-signed X.509 v3 certificate.
generate.san is required and accepts dns:name and ip:address entries;
hostname verification therefore never depends on the deprecated Common Name.
The certificate uses ECDSA with SHA-256, a random 128-bit serial, critical
CA-false basic constraints and digital-signature key usage, plus server_auth
extended usage by default. client_auth may be selected or combined with it.
Validity defaults to 30d and accepts 1d through 200d, matching the current
CA/Browser Forum maximum for publicly trusted subscriber certificates even
though SecretSpec’s self-signed development identities are outside that policy.
The start time is backdated five minutes for peer clock skew without extending
the requested total lifetime.
The canonical value is an empty-password PKCS#12 archive. It explicitly uses
AES-256-CBC for key and certificate protection and HMAC-SHA-256 for integrity,
avoiding legacy RC2, 3DES, and SHA-1 profiles. Because the password is empty,
the archive is an interoperable container rather than an independent security
boundary: store it in a provider that protects secrets at rest. encoding
defaults to base64, so inline exposure is Base64 and as_path = true writes
the PFX bytes to an owner-only .pfx file. Derive PEM, DER, key, certificate,
chain, and password protected PFX values with from; see
Derived typed secrets. A provider-backed
x509_identity may also be an imported archive, protected or not.
Behavior
Section titled “Behavior”- Generation only triggers when a secret is missing — existing secrets are never overwritten
- Generated values are stored via the secret’s configured provider (or the default provider)
- With
providers = ["null"](0.19+), a fresh generated value is returned only for the current resolution and is not written to provider storage - Subsequent runs find the stored value and skip generation (idempotent)
generateanddefaultcannot both be set on the same secrettype = "command"requiresgenerate = { command = "..." }(not justgenerate = true)type = "openpgp_private_key"(0.21+) requiresgenerate.user_id; omittedalgorithmandcapabilitiesdefault to Ed25519/Curve25519 and both signing and encryption, respectivelytype = "ssh_private_key"(0.21+) defaults to Ed25519; RSA generation is available withgenerate = { algorithm = "rsa", bits = 4096 }passphrase,mnemonic,wireguard_private_key,jwk_private_key,age_identity, andx509_identitygeneration require SecretSpec 0.21+- The value-free preflights —
check --json/check --explainand the SDKs’ report/no-values resolutions — never mint a value. Since SecretSpec 0.20 a required generatable secret that no provider holds is reported asmissing_requiredthere (and exits non-zero) until acheckorrunprovisions it; an optional one, or one stored in a provider that never retains generated values such asnull, is reported as will generate
Profile Inheritance
Section titled “Profile Inheritance”- Non-default profiles inherit from
[profiles.default]when it exists;profiles.<name>.defaults.inherit = falsemakes a profile standalone in SecretSpec 0.19+ - Profile-specific values override default values
refandrefs(0.19+) are alternative forms of one setting: declaring either in a profile replaces the form inherited from[profiles.default], while declaring neither inherits it- Use the
extendsfield in[project]to inherit from other secretspec.toml files