Secret Management¶
Confii resolves ${secret:key} placeholders while initializing the effective
configuration. It first loads and merges all sources, selects the active
environment, discovers the remaining secret references, deduplicates reads of
the same remote document, and resolves them before confii.New returns.
Ordinary getters therefore read a ready in-memory snapshot and do not trigger
provider traffic. Stores are pluggable -- from in-memory dictionaries to AWS
Secrets Manager, Azure Key Vault, GCP Secret Manager, Vault, and OpenBao.
How Placeholders Work¶
When a config value contains a ${secret:...} placeholder, the configured
resolver replaces it during startup. Only references in the final selected
environment are contacted; references exclusive to inactive environments are
not. Missing or inaccessible required secrets make initialization fail without
publishing a partially resolved Config.
Independent top-level branches resolve concurrently (default limit: four),
while duplicate provider/key/version requests are coalesced. Configure the
bound with secret_resolution_concurrency or
WithSecretResolutionConcurrency. Context cancellation always stops the
operation, regardless of on_error. Declaratively created providers that
implement Close() error are released by Config.Close.
database:
host: prod-db.example.com
password: ${secret:db/password}
url: postgres://admin:${secret:db/password}@prod-db:5432/mydb
password, _ := cfg.Get("database.password")
// already resolved in memory; no provider request occurs here
url, _ := cfg.Get("database.url")
// inline references were also resolved before New returned
Resolution lifecycle¶
load sources → merge → select environment → discover references
→ deduplicate provider reads → resolve → validate → publish
If two keys select different JSON fields from the same secret document, Confii
fetches that document once during the materialization pass and extracts both
fields locally. A normal Get, Typed, ToDict, or Export call does not
refresh secrets. Rotation is explicit and transactional:
if err := cfg.RefreshSecretsWithContext(ctx); err != nil {
// The previous ready configuration remains active.
}
Reload performs the same eager materialization after rebuilding the source
layers. A failed provider read or validation leaves the prior configuration
active. If another mutation publishes while a refresh candidate is being
resolved, Confii discards that candidate and retries from the newest unresolved
snapshot. A concurrent Freeze or Close prevents publication even when the
provider request had already started. Only the committed attempt invokes change
callbacks, records metrics, or emits lifecycle events.
Hooks must be supplied before construction with confii.WithSecretHook,
confii.WithSecretResolver, or the general construction-time hook options.
The plan is frozen after New succeeds and every access surface observes the
same published values. See Hooks.
Placeholder Formats¶
The default-provider forms have increasing specificity:
Basic: ${secret:key}¶
Fetch the entire secret value by key:
With JSON Path: ${secret:key:json_path}¶
When a secret is a JSON object, extract a specific field using dot-notation:
# Secret "db/credentials" contains: {"username": "admin", "password": "s3cret"}
db_user: ${secret:db/credentials:username}
db_pass: ${secret:db/credentials:json_path}
The JSON path supports nested traversal:
# Secret "config/nested" contains: {"level1": {"level2": {"value": "deep"}}}
deep_value: ${secret:config/nested:level1.level2.value}
With Version: ${secret:key:json_path:version}¶
Fetch a specific version of the secret:
# Fetch version 2 of the secret, extract the "password" field
db_pass: ${secret:db/credentials:password:2}
# Fetch version "AWSPREVIOUS" (AWS-specific stage)
old_key: ${secret:api/key::AWSPREVIOUS}
Empty JSON path
Use an empty JSON path segment to skip it when you only need versioning: ${secret:key::version}.
With an explicit provider: ${secret@provider:key}¶
Declarative named-provider configurations can route each reference to a specific backend. The provider qualifier works with JSON paths and versions:
shared_key: ${secret@vault:platform/signing:key}
payment_key: ${secret@aws-production:payments/api-key::AWSCURRENT}
analytics_token: ${secret@gcp:analytics-token}
An unqualified ${secret:key} uses the default provider selected for the
active environment. secret@provider is distinct from the colon-delimited
key, field, and version grammar, keeping provider routing unambiguous.
Working with references programmatically¶
Confii owns its reference grammar, so a consumer never needs its own parser or serializer:
import "github.com/confiify/confii-go/v2/secret"
ref, err := secret.ParseReference("${secret@vault:db/creds:password:3}")
// ref.Provider == "vault", ref.Key == "db/creds",
// ref.Field == "password", ref.Version == "3"
canonical := ref.String() // "${secret@vault:db/creds:password:3}"
ParseReference is strict: the reference must occupy the whole value, and
surrounding text is an error. For values that mix references with other text,
such as a connection string, use FindReferences:
Parsing is purely syntactic. No provider is contacted and no registry is
consulted, so a reference naming a provider that is not configured still parses
— routing is resolved later, when the configuration materializes. Parse errors
are typed as *secret.ReferenceError and name only the locator, never a
resolved value.
Escaping¶
There is none. Components are delimited by : and terminated by }, and a
component may not contain : or }. Every other character is ordinary, {
and $ included. A key containing a delimiter is not representable, and the
parser rejects such input rather than truncating it silently. Choose keys that
avoid the delimiters.
Building a reference by hand¶
Reference has exported fields, so a value can be built that the grammar
cannot express — Reference{Key: "key:segment"} names a key no reference can
spell.
Validate() errorreports it, assecret.ErrUnrepresentableReference.MarshalText() ([]byte, error)returns it as an error, so aReferencewritten into JSON or YAML fails loudly rather than silently.String()cannot return an error, so it answers with a diagnostic —%!secret(key must not contain ':' or '}')— instead of text.
That last one matters more than it looks. Writing the components out regardless
would render Reference{Key: "key:segment"} as ${secret:key:segment}, which
is a well-formed reference to the key secret's segment field: a different
secret than the fields named, and one that anything reading the value back
would happily resolve. A Reference either serializes to something that parses
back to itself, or to something that is not a reference at all.
Anything returned by ParseReference or FindReferences is valid by
construction and needs none of this.
Compatibility¶
The grammar is part of Confii's public interface. Within a major version, a
string that parses today will keep parsing to an equal Reference, and
String will keep producing a form that re-parses equally for every
Reference that Validate accepts. New optional components may be added only
in positions that cannot change the meaning of an existing reference.
Resolved values cannot introduce references¶
A resolution never produces a value that a further pass would resolve.
Substitution can otherwise manufacture a reference that exists in neither the
template nor the secret alone. A value ending in $ completes a {...}
sequence in the text after it, so this template:
with a holding trailing$ would produce trailing${secret:b} — a reference
nobody wrote. A secret whose value is itself ${secret:other} has the same
effect directly, chaining one secret read into another.
Confii rejects both. If substitution produces text matching the reference
grammar, the resolution fails with ErrSecretValidation, the input is returned
unchanged, and the manufactured reference is never read from the store:
if errors.Is(err, confii.ErrSecretValidation) {
// A resolved secret spelled a new reference. Check the values behind the
// locators named in the error.
}
The error names the locators the template asked for and quotes nothing that was resolved, because the synthesized reference is built from resolved material.
Built-in Stores¶
DictStore¶
In-memory store for testing and development. Supports versioning via SetSecret.
import "github.com/confiify/confii-go/v2/secret"
store := secret.NewDictStore(map[string]any{
"db/password": "s3cret",
"api/key": "ak-12345",
"config/nested": map[string]any{
"username": "admin",
"password": "hunter2",
},
})
// Additional operations
store.SetSecret(ctx, "db/password", "new-password") // creates a new version
store.DeleteSecret(ctx, "api/key")
keys, _ := store.ListSecrets(ctx, "db/") // ["db/password"]
store.Clear() // remove all
EnvStore¶
Retrieves secrets from OS environment variables. Keys are transformed to uppercase with /, ., and - replaced by _.
import "github.com/confiify/confii-go/v2/secret"
store := secret.NewEnvStore(
secret.WithEnvPrefix("SECRET_"), // prepend prefix
secret.WithEnvSuffix("_VALUE"), // append suffix
secret.WithTransformKey(true), // default: uppercase + replace separators
)
Key transformation example:
Secret key: "db/password"
→ Transform: "DB_PASSWORD"
→ With prefix/suffix: "SECRET_DB_PASSWORD_VALUE"
→ Looks up: os.Getenv("SECRET_DB_PASSWORD_VALUE")
MultiStore¶
Tries multiple stores in priority order. The first store that successfully returns a value wins.
import "github.com/confiify/confii-go/v2/secret"
multi := secret.NewMultiStore(
[]confii.SecretStore{vaultStore, awsStore, envStore},
secret.WithWriteToFirst(true), // writes go to first store only
)
Fallback behavior:
GetSecret("db/password"):
1. Try vaultStore → not found
2. Try awsStore → found! return value
(envStore is never tried)
Order matters
Put your most authoritative store first. Cloud stores should come before the env fallback for production, but you might reverse this order for local development.
Optional store capabilities¶
SecretStore is the portable read/write contract. Applications can
feature-detect two additional capabilities without coupling themselves to a
specific provider:
if checker, ok := store.(confii.SecretExistenceChecker); ok {
exists, err := checker.SecretExists(ctx, "db/password")
// Existence is checked without returning secret material.
}
if metadataProvider, ok := store.(confii.SecretMetadataProvider); ok {
metadata, err := metadataProvider.GetSecretMetadata(ctx, "db/password")
// Metadata must never contain the secret value.
}
Providers are not required to implement these interfaces. DictStore
implements both for local development and tests; cloud integrations may expose
them when the provider offers a value-safe operation. Applications must retain
the ordinary GetSecret path when the capability assertion is false.
Cloud Stores¶
Cloud stores live in the separate secret/cloud module and require provider
build tags to compile. This keeps the binary small when you don't need them.
AWS Secrets Manager¶
import "github.com/confiify/confii-go/secret/cloud/v2"
store, err := cloud.NewAWSSecretsManager(ctx,
cloud.WithAWSRegion("us-east-1"),
cloud.WithAWSCredentials("AKIA...", "secret...", ""), // optional, uses default chain
cloud.WithAWSEndpoint("http://localhost:4566"), // LocalStack for testing
)
AWS-specific version stages: AWSCURRENT, AWSPENDING, AWSPREVIOUS are recognized as stage names rather than version IDs.
Azure Key Vault¶
import "github.com/confiify/confii-go/secret/cloud/v2"
// Uses DefaultAzureCredential (managed identity, env vars, CLI, etc.)
store, err := cloud.NewAzureKeyVault(
"https://my-vault.vault.azure.net",
nil, // nil = DefaultAzureCredential
)
Azure Key Vault name restrictions
Secret names must match ^[0-9a-zA-Z-]+$. Names with /, ., or _ will be rejected.
GCP Secret Manager¶
import "github.com/confiify/confii-go/secret/cloud/v2"
store, err := cloud.NewGCPSecretManager(ctx,
"my-gcp-project",
cloud.WithGCPCredentialsFile("/path/to/service-account.json"), // optional
)
When no version is specified, GCP defaults to "latest".
HashiCorp Vault and OpenBao¶
import "github.com/confiify/confii-go/secret/cloud/v2"
store, err := cloud.NewHashiCorpVault(
cloud.WithVaultURL("https://vault.example.com:8200"),
cloud.WithVaultToken("hvs.xxxxx"),
cloud.WithVaultNamespace("my-team"),
cloud.WithVaultMountPoint("secret"), // default: "secret"
cloud.WithVaultKVVersion(2), // default: 2
cloud.WithVaultVerify(true), // TLS verification, default: true
)
OpenBao uses the same build tag, options, authentication implementations, and KV v1/v2 behavior. Use the explicit constructor when the server is OpenBao:
store, err := cloud.NewOpenBao(
cloud.WithVaultURL("https://openbao.example.com:8200"),
cloud.WithVaultAuth(&cloud.AppRoleAuth{
RoleID: roleID,
SecretID: secretID,
}),
)
Confii's CI starts a real, digest-pinned OpenBao 2.6.1 server and verifies KV
write, read, field extraction, list, delete, token authentication, and AppRole
authentication. The shared implementation deliberately retains the existing
VaultOption; all constructors return the vendor-neutral VaultStore type.
Field extraction uses the provider-neutral WithField option:
// Fetch only the "password" field from secret/data/db/credentials
val, _ := store.GetSecret(ctx, "db/credentials", confii.WithField("password"))
Hermetic construction¶
By default the Vault SDK reads about twenty environment variables. Among them
is VAULT_SKIP_VERIFY, which silently disables certificate verification,
and the standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY variables. Anything
that can set an environment variable on the process can therefore weaken
transport security without the caller's knowledge.
WithVaultHermetic builds the client from caller-supplied options only:
store, err := cloud.NewVaultWithContext(ctx,
cloud.WithVaultHermetic(),
cloud.WithVaultURL("https://vault.example.com:8200"),
cloud.WithVaultNamespace("my-team"),
cloud.WithVaultTLS(cloud.VaultTLS{
CACertPEM: caPEM, // explicit bytes, never a discovered path
ClientCertPEM: certPEM, // optional mutual TLS
ClientKeyPEM: keyPEM,
}),
cloud.WithVaultProxy(proxyURL), // omit to disable proxying entirely
cloud.WithVaultTimeout(5*time.Second),
cloud.WithVaultRetryLimit(2),
cloud.WithVaultAuth(auth),
)
In hermetic mode:
- Address, namespace, token, headers, TLS material, proxy, timeout, retry limit, and redirect policy come from options alone.
- The store owns its
http.Clientandhttp.Transport.http.DefaultTransportis neither used nor modified. - Proxying is off unless
WithVaultProxyis supplied. - Certificate verification is always on and cannot be disabled.
WithVaultVerifyis ignored, so no ambient variable can weaken it. - Redirects are refused unless
WithVaultFollowRedirects(true)is supplied. - The process environment is never modified.
One documented limitation. A hermetic client never adopts an ambient
value, but it cannot stop the SDK from parsing the environment:
api.NewClient builds api.DefaultConfig internally before reading the
configuration it is given. A malformed ambient value — an unparseable
VAULT_MAX_RETRIES, VAULT_CLIENT_TIMEOUT, VAULT_SKIP_VERIFY,
VAULT_SRV_LOOKUP, VAULT_DISABLE_REDIRECTS, or an unreadable VAULT_CACERT,
VAULT_CAPATH, VAULT_CACERT_BYTES, VAULT_CLIENT_CERT, VAULT_CLIENT_KEY —
therefore fails construction with ErrVaultAmbientEnvironment:
if errors.Is(err, cloud.ErrVaultAmbientEnvironment) {
// An ambient VAULT_* variable is malformed. Correct or unset it in the
// environment that launches the process.
}
Clearing the variable for the duration of the call would mutate process-global state shared with every goroutine, so the condition is reported rather than worked around. The failure is always explicit; hermetic mode never falls back to ambient settings.
Environment hygiene¶
The limitation above disappears entirely if the process starts with a clean environment, and that is the recommended deployment for security-sensitive services. A hermetic client reads nothing from the environment, so leaving the variables unset costs nothing and removes the only remaining way the environment can affect Vault access.
Do not set any of these when using hermetic construction:
VAULT_ADDR VAULT_CACERT VAULT_CLIENT_CERT
VAULT_AGENT_ADDR VAULT_CACERT_BYTES VAULT_CLIENT_KEY
VAULT_NAMESPACE VAULT_CAPATH VAULT_CLIENT_TIMEOUT
VAULT_TOKEN VAULT_SKIP_VERIFY VAULT_TLS_SERVER_NAME
VAULT_MAX_RETRIES VAULT_SRV_LOOKUP VAULT_DISABLE_REDIRECTS
VAULT_PROXY_ADDR VAULT_HTTP_PROXY VAULT_HEADERS
HTTP_PROXY HTTPS_PROXY NO_PROXY
In Kubernetes, the risk is usually an injected sidecar or a shared ConfigMap rather than anything the application declares. Confirm what the container actually receives:
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
# Do not source a ConfigMap or Secret that carries VAULT_* keys.
env:
- name: CONFII_ENV
value: production
# Verify at runtime rather than trusting the manifest.
kubectl exec deploy/app -- env | grep -E '^(VAULT_|HTTP_PROXY|HTTPS_PROXY|NO_PROXY)' || echo clean
For a container entrypoint that cannot guarantee its parent environment, clear the variables before exec:
#!/bin/sh
# Hermetic construction ignores these; unsetting them also removes the one
# way a malformed value could still fail startup.
for name in VAULT_ADDR VAULT_AGENT_ADDR VAULT_CACERT VAULT_CACERT_BYTES \
VAULT_CAPATH VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CLIENT_TIMEOUT \
VAULT_HEADERS VAULT_NAMESPACE VAULT_MAX_RETRIES VAULT_PROXY_ADDR \
VAULT_HTTP_PROXY VAULT_SKIP_VERIFY VAULT_SRV_LOOKUP VAULT_TLS_SERVER_NAME \
VAULT_TOKEN VAULT_DISABLE_REDIRECTS; do
unset "$name"
done
exec /app "$@"
This is defence in depth, not a correctness requirement. A hermetic client is already immune to the values of these variables; clearing them additionally removes the malformed-value failure described above.
Ambient mode¶
Constructors called without WithVaultHermetic retain the SDK's environment
discovery, including VAULT_ADDR, VAULT_TOKEN, VAULT_NAMESPACE,
VAULT_SKIP_VERIFY, and the proxy variables. This mode is kept for
compatibility and for deployments that intentionally configure Vault through
the environment. Prefer hermetic construction for security-sensitive services.
Resolver lifecycle¶
A resolver holds secret material in memory: cached values, and provider clients
holding connections. ClearCache invalidates the cache but is not a shutdown
contract, because a provider read already in flight can populate the cache
immediately after it returns.
Close is the shutdown contract:
It rejects new resolution with ErrResolverClosed, cancels in-flight reads,
waits for each to finish including its cache write, drops cached values, and
closes the store when the store supports it. It is idempotent and safe to call
concurrently; every caller sees the same result.
Config.Close closes the resolver automatically when it implements
confii.CloseableSecretResolver, so a configuration that owns its resolver
needs no separate teardown:
cfg, err := confii.New[Settings](confii.WithSecretResolver(resolver))
defer cfg.Close() // closes the resolver and its store
What close does and does not promise¶
Close bounds ownership and retention. After it returns the resolver holds no cached secret, performs no further provider reads, and hands out no further values.
It does not erase memory, and no Go library can honestly promise that. A resolved secret may have been copied into caller structures, retained by the runtime, or left in garbage not yet collected. Treat material already returned to you as yours to manage; confii guarantees only that it keeps none of it.
Vault Auth Methods¶
The Vault-compatible integration exposes adapters for nine authentication methods. AppRole, Kubernetes, AWS IAM, Azure managed identity, and GCP use the official HashiCorp Vault auth packages for credential discovery, signing, and login payload construction. Token, LDAP, generic JWT, and interactive OIDC use the Vault API directly because HashiCorp does not publish corresponding Go auth helpers for those flows.
Pass one method via WithVaultAuth. CI live-tests Token and AppRole against
OpenBao and exercises every other adapter against protocol fixtures. Provider
identity, role, and trust configuration remains deployment-specific and must be
verified in the target environment:
cloud.WithVaultAuth(&cloud.AppRoleAuth{
RoleID: "role-id",
SecretIDEnv: "VAULT_SECRET_ID",
MountPoint: "approle", // default: "approle"
})
Exactly one of SecretID, SecretIDFile, or SecretIDEnv is required.
File and environment sources are read for each authentication attempt, so
rotated credentials do not require rebuilding the store. Set
WrappingToken when that source contains a Vault response-wrapping token.
cloud.WithVaultAuth(&cloud.LDAPAuth{
Username: "admin",
Password: "password",
MountPoint: "ldap", // default: "ldap"
})
// Or with a password provider function:
cloud.WithVaultAuth(&cloud.LDAPAuth{
Username: "admin",
PasswordProvider: func(ctx context.Context) (string, error) {
return os.Getenv("VAULT_LDAP_PASSWORD"), nil
},
})
cloud.WithVaultAuth(&cloud.KubernetesAuth{
Role: "my-k8s-role",
TokenPath: "/var/run/secrets/kubernetes.io/serviceaccount/token",
MountPoint: "kubernetes", // default: "kubernetes"
})
Supply at most one of JWT, TokenPath, or TokenEnv. With none set, the
official package reads the standard projected service-account token path.
cloud.WithVaultAuth(&cloud.AWSIAMAuth{
Role: "my-aws-role",
Region: "us-east-1",
IAMServerIDHeader: "vault.example.com", // optional role binding
MountPoint: "aws", // default: "aws"
})
The official auth package discovers the standard AWS credential chain and
signs the STS GetCallerIdentity request. Applications with an external
signer can instead use AWSIAMSignedRequestAuth and provide Vault's four
base64-encoded IAM request fields explicitly.
cloud.WithVaultAuth(&cloud.AzureAuth{
Role: "my-azure-role",
Resource: "https://management.azure.com/", // optional audience
MountPoint: "azure", // default: "azure"
})
AzureAuth uses the official package to obtain a managed-identity token and
instance metadata from Azure IMDS. Workload identities that already own a
JWT can use the explicit AzureJWTAuth adapter.
cloud.WithVaultAuth(&cloud.GCPAuth{
Role: "my-gcp-role",
AuthType: "iam", // "gce" is the default
ServiceAccountEmail: "app@project.iam.gserviceaccount.com",
MountPoint: "gcp", // default: "gcp"
})
GCE mode obtains an identity JWT from the metadata service. IAM mode signs
through IAM Credentials using application default credentials. An external
identity JWT can use JWTAuth with MountPoint: "gcp".
cloud.WithVaultAuth(&cloud.OIDCAuth{
Role: "my-oidc-role",
MountPoint: "oidc", // default: "oidc"
RedirectURI: "http://localhost:8250/oidc/callback",
})
OIDC starts a loopback callback server, opens the provider login in the default browser, validates the returned state and nonce, and exchanges the authorization code with Vault. The redirect URI must be allowed by both the Vault role and the OIDC provider. Embedded/headless applications can set CallbackProvider to collect and return the full callback URL themselves; CallbackTimeout and OpenBrowser customize the interactive flow.
WithVaultAppRole is shorthand for AppRole authentication:
Strict Vault configuration¶
strict: true makes the declared settings the sole authority:
secrets:
providers:
vault:
strict: true
address: https://vault.internal:8200
namespace: platform
mount: secret
kv_version: 2
timeout: 7s
retry_limit: 4
proxy: http://egress.internal:8080 # omit to disable proxying
follow_redirects: false
tls:
ca_cert_pem: |
-----BEGIN CERTIFICATE-----
...
server_name: vault.internal
auth:
method: kubernetes
role: my-service
Under strict:
VAULT_ADDRandVAULT_TOKENare not consulted. An address must be declared; a token need not be, because an auth method may supply one.- The transport is built hermetically, so no ambient variable shapes it.
- An unrecognized setting is an error. A typo such as
retry_limtfails loudly instead of leaving the real setting at its default.
Errors name the setting at fault and never its value, which may be a credential.
Without strict, the provider keeps its convenience fallbacks to VAULT_ADDR
and VAULT_TOKEN. That is useful for local work and wrong for a deployment that
means to declare everything.
The bootstrap boundary¶
Some information must exist before the first Vault request can be made, and it cannot itself come from Vault. That set is deliberately small:
| Bootstrap input | Why it cannot be resolved |
|---|---|
address |
needed to reach Vault at all |
tls.ca_cert_pem |
needed to verify Vault's certificate |
| auth material — a Kubernetes service-account token, a cloud instance identity | needed to obtain a Vault token |
Everything else is ordinary configuration and may reference secrets. Prefer auth methods whose bootstrap input is supplied by the platform rather than stored: Kubernetes workload identity, cloud instance identity, or Vault Agent. AppRole needs a secret ID delivered through a controlled channel, so treat it as a bootstrap credential with a short lifetime.
Closing¶
VaultStore releases idle connections on Close, and the declarative provider
forwards that, so a store built from configuration is closed by Config.Close
along with the resolver. Closing does not revoke the Vault token: its lifetime
belongs to Vault's lease and may be shared with another client.
Declarative Self-Config Providers¶
Cloud stores can be wired through .confii.yaml when the application
blank-imports github.com/confiify/confii-go/secret/cloud/v2 and builds with
the matching tag. Each tagged package registers its provider during init.
Confii v2 requires named providers, including when only one provider is used:
secrets:
default_provider: vault
environment_defaults:
production: aws-production
analytics: gcp-analytics
providers:
vault:
type: vault
address: https://vault.internal:8200
mount_point: secret
kv_version: 2
auth:
method: token
aws-production:
type: aws
region: us-east-1
gcp-analytics:
type: gcp
project_id: analytics-production
shared:
type: vault
mount_point: shared
kv_version: 2
auth:
method: token
With production selected, ${secret:database/password} uses
aws-production. ${secret@shared:services/signing:key} uses shared in
every environment. Provider aliases are application-defined; type selects
the registered implementation. Factories initialize lazily on the first
reference, so selecting production does not require credentials for an unused
development provider.
The effective order is:
- An explicit
secret@providerqualifier. environment_defaults[active_environment].default_provider.
An unqualified reference with no effective default fails closed. An unknown provider alias, unsupported build-tagged provider type, unavailable backend, missing field, or unsupported versioned read also fails closed.
Provider-specific fields:
| Provider | Required/configuration fields |
|---|---|
aws |
region; optional access_key, secret_key, session_token, endpoint (otherwise the AWS default credential chain is used) |
azure |
vault_url (aliases: address, url); Azure Default Credential is used |
gcp |
project_id; optional credentials_file (otherwise Application Default Credentials are used) |
vault |
address or VAULT_ADDR; optional namespace, mount_point, kv_version, verify, and auth |
Vault self-config accepts token, approle, ldap, jwt, kubernetes,
aws_iam, azure, gcp, and interactive oidc. The official-provider forms
also accept their provider-specific fields: AppRole secret ID sources,
Kubernetes token sources, AWS region, Azure resource, and GCP auth_type
plus service_account_email. Advanced external-identity forms are named
explicitly: aws_signed_request, azure_jwt, and gcp_jwt.
This is configuration support, not a claim that every provider identity is
turnkey or live-certified. Token and AppRole are the CI-tested OpenBao paths;
the others require provider-side identity configuration. auth may be a
method string with fields alongside it or a nested map with method. A root
token or VAULT_TOKEN is used for token auth. The same build can register
multiple providers by enabling multiple tags, for example -tags="aws,vault".
After configuration contains at least one ${secret:...} reference, run the
value-safe preflight before deployment:
The standard installed CLI intentionally has no cloud SDKs. Use an application operational binary that imports the selected provider modules as described in the CLI connection test. The command authenticates and performs real reads through the normal resolution hook, then discards every value.
Resolver Options¶
The Resolver bridges a secret store with the hook system:
import "github.com/confiify/confii-go/v2/secret"
resolver := secret.NewResolver(store,
secret.WithCache(true), // enable caching (default: true)
secret.WithCacheTTL(5 * time.Minute), // cache expiration (0 = no expiry)
secret.WithResolverPrefix("prod/"), // prepend to all keys
)
| Option | Default | Description |
|---|---|---|
WithCache(bool) |
true |
Enable/disable internal cache |
WithCacheTTL(duration) |
0 (no expiry) |
How long cached values are valid |
WithResolverPrefix(string) |
"" |
Prepended to all secret keys before lookup |
Missing references always return a typed error in v2; unresolved placeholders are never published as configuration.
Cache Management¶
// View cache statistics
stats := resolver.CacheStats()
// {"enabled": true, "size": 5, "keys": ["db/password:", ...]}
// Pre-populate cache at startup
resolver.Prefetch(ctx, []string{"db/password", "api/key", "tls/cert"})
// Clear all cached values
resolver.ClearCache()
Imperative resolver wiring¶
When declarative self-configuration is not appropriate, pass the resolver's
context-aware hook to New. Constructor-time wiring participates in eager,
fail-fast materialization:
// Create store and resolver
store := secret.NewDictStore(map[string]any{
"db/password": "s3cret",
"api/key": "ak-12345",
})
resolver := secret.NewResolver(store,
secret.WithCache(true),
secret.WithCacheTTL(5 * time.Minute),
)
cfg, err := confii.NewWithContext[any](ctx,
confii.WithLoaders(loader.NewYAML("config.yaml")),
confii.WithSecretResolver(resolver),
)
if err != nil {
return err
}
// Already resolved; this is an in-memory read.
password, _ := cfg.Get("database.password")
Initialization ordering
Eager materialization follows the built-in order: environment expansion,
type casting, then constructor-time secret resolution. Register every hook
through constructor options or the builder; the materialization plan is
immutable after New succeeds.
Multi-Store Fallback Chain¶
Combine multiple stores for environment-flexible secret resolution:
package main
import (
"context"
"time"
"github.com/confiify/confii-go/v2"
"github.com/confiify/confii-go/v2/loader"
"github.com/confiify/confii-go/v2/secret"
"github.com/confiify/confii-go/secret/cloud/v2"
)
func main() {
ctx := context.Background()
// Primary: HashiCorp Vault
vaultStore, _ := cloud.NewHashiCorpVault(
cloud.WithVaultURL("https://vault.example.com:8200"),
cloud.WithVaultAuth(&cloud.AppRoleAuth{
RoleID: "my-role-id",
SecretID: "my-secret-id",
}),
)
// Secondary: AWS Secrets Manager
awsStore, _ := cloud.NewAWSSecretsManager(ctx,
cloud.WithAWSRegion("us-east-1"),
)
// Fallback: Environment variables
envStore := secret.NewEnvStore(
secret.WithEnvPrefix("SECRET_"),
)
// Multi-store: try Vault, then AWS, then env vars
multi := secret.NewMultiStore(
[]confii.SecretStore{vaultStore, awsStore, envStore},
secret. )
// Resolver with caching
resolver := secret.NewResolver(multi,
secret.WithCache(true),
secret.WithCacheTTL(10 * time.Minute),
)
// Load, consolidate, and resolve before returning.
cfg, err := confii.NewWithContext[any](ctx,
confii.WithLoaders(loader.NewYAML("config.yaml")),
confii.WithEnv("production"),
confii.WithSecretResolver(resolver),
)
if err != nil {
panic(err)
}
// All ${secret:...} placeholders are now resolved through the chain
dbPass, _ := cfg.Get("database.password")
apiKey, _ := cfg.Get("api.key")
_ = dbPass
_ = apiKey
}
Complete Example¶
package main
import (
"context"
"time"
"github.com/confiify/confii-go/v2"
"github.com/confiify/confii-go/v2/loader"
"github.com/confiify/confii-go/v2/secret"
)
func main() {
ctx := context.Background()
// Create a secret store (DictStore for demo; use cloud stores in production)
store := secret.NewDictStore(map[string]any{
"db/password": "super-s3cret",
"api/credentials": map[string]any{
"key": "ak-prod-12345", // gitleaks:allow -- illustrative value
"secret": "sk-prod-67890", // gitleaks:allow -- illustrative value
},
"tls/cert": "-----BEGIN CERTIFICATE-----\n...",
})
// Create resolver with caching
resolver := secret.NewResolver(store,
secret.WithCache(true),
secret.WithCacheTTL(5 * time.Minute),
secret. )
// Load, resolve all effective references, and validate before returning.
cfg, err := confii.NewWithContext[any](ctx,
confii.WithLoaders(loader.NewYAML("config.yaml")),
confii.WithEnv("production"),
confii.WithSecretResolver(resolver),
)
if err != nil {
panic(err)
}
// Access already-resolved values without provider traffic.
dbPass, _ := cfg.Get("database.password")
apiKey, _ := cfg.Get("api.key")
dbURL, _ := cfg.Get("database.url")
_ = dbPass // use to construct the database client; never log it
_ = apiKey // use to construct the API client; never log it
_ = dbURL
}
default:
database:
host: localhost
port: 5432
password: ${secret:db/password}
url: postgres://admin:${secret:db/password}@localhost:5432/mydb
api:
key: ${secret:api/credentials:key}
secret: ${secret:api/credentials:secret}
production:
database:
host: prod-db.example.com
url: postgres://admin:${secret:db/password}@prod-db:5432/mydb