Skip to content

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.

Confii secret resolution flow


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.

config.yaml
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:

api_key: ${secret:services/api-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:

refs := secret.FindReferences("postgres://u:${secret:db/password}@host/db")

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() error reports it, as secret.ErrUnrepresentableReference.
  • MarshalText() ([]byte, error) returns it as an error, so a Reference written 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:

token: ${secret:a}{secret:b}

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")
export SECRET_DB_PASSWORD_VALUE=s3cret

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

go build -tags aws
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

go build -tags azure
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

go build -tags gcp
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

go build -tags vault
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.Client and http.Transport. http.DefaultTransport is neither used nor modified.
  • Proxying is off unless WithVaultProxy is supplied.
  • Certificate verification is always on and cannot be disabled. WithVaultVerify is 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:

resolver := secret.NewResolver(store)
defer resolver.Close()

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.TokenAuth{
    Token: "hvs.xxxxx",
})
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.JWTAuth{
    Role:       "my-role",
    JWT:        "eyJhbGci...",
    MountPoint: "jwt",  // default: "jwt"
})
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:

cloud.WithVaultAppRole("role-id", "secret-id")

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_ADDR and VAULT_TOKEN are 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_limt fails 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:

  1. An explicit secret@provider qualifier.
  2. environment_defaults[active_environment].
  3. 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:

confii connections test production --timeout 20s

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

main.go
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
}
config.yaml
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