Secret Management¶
Confii resolves ${secret:key} placeholders in configuration values at access time through the hook system. Secrets are fetched from pluggable stores -- from simple in-memory dictionaries to cloud providers like AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, and HashiCorp Vault.
How Placeholders Work¶
When a config value contains a ${secret:...} placeholder, the secret resolver (registered as a global hook) replaces it with the actual secret value. This happens transparently during Get calls -- your application code reads resolved values without knowing they came from a secret store.
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")
// "s3cret-passw0rd" (resolved from secret store)
url, _ := cfg.Get("database.url")
// "postgres://admin:s3cret-passw0rd@prod-db:5432/mydb" (inline replacement)
Placeholder Formats¶
Three formats with 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}.
Built-in Stores¶
DictStore¶
In-memory store for testing and development. Supports versioning via SetSecret.
import "github.com/confiify/confii-go/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/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/secret"
multi := secret.NewMultiStore(
[]confii.SecretStore{vaultStore, awsStore, envStore},
secret.WithFailOnMissing(true), // error if no store has the key
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.
Cloud Stores¶
Cloud stores require 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"
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"
// 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"
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¶
import "github.com/confiify/confii-go/secret/cloud"
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
)
Vault also supports the "path:field" syntax for extracting specific fields:
// Fetch only the "password" field from secret/data/db/credentials
val, _ := store.GetSecret(ctx, "db/credentials:password")
Vault Auth Methods¶
HashiCorp Vault supports 9 authentication methods. Pass them via WithVaultAuth:
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() (string, error) {
return os.Getenv("VAULT_LDAP_PASSWORD"), nil
},
})
You can also use the shorthand WithVaultAppRole for AppRole auth:
Resolver Options¶
The Resolver bridges a secret store with the hook system:
import "github.com/confiify/confii-go/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
secret.WithResolverFailOnMissing(true), // error on unresolved secrets (default: true)
)
| 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 |
WithResolverFailOnMissing(bool) |
true |
Return error for unresolvable secrets |
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()
Wiring Resolver with HookProcessor¶
The resolver's Hook() method returns a hook.Func that you register as a global hook:
cfg, _ := confii.New[any](ctx,
confii.WithLoaders(loader.NewYAML("config.yaml")),
)
// 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),
)
// Register as global hook
cfg.HookProcessor().RegisterGlobalHook(resolver.Hook())
// Now all ${secret:...} placeholders are resolved automatically
password, _ := cfg.Get("database.password")
// "s3cret"
Hook ordering matters
The secret resolver hook should typically be registered after the env expander hook. This way, ${VAR} expansion happens first (resolving env vars), and then ${secret:...} resolution runs on the result. Since built-in hooks (env expander, type cast) are registered during New(), your custom hooks added after creation will naturally run later in the global hook chain.
Multi-Store Fallback Chain¶
Combine multiple stores for environment-flexible secret resolution:
package main
import (
"context"
"time"
"github.com/confiify/confii-go"
"github.com/confiify/confii-go/loader"
"github.com/confiify/confii-go/secret"
"github.com/confiify/confii-go/secret/cloud"
)
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.WithFailOnMissing(true),
)
// Resolver with caching
resolver := secret.NewResolver(multi,
secret.WithCache(true),
secret.WithCacheTTL(10 * time.Minute),
)
// Load config and wire up secret resolution
cfg, _ := confii.New[any](ctx,
confii.WithLoaders(loader.NewYAML("config.yaml")),
confii.WithEnv("production"),
)
cfg.HookProcessor().RegisterGlobalHook(resolver.Hook())
// 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"
"fmt"
"time"
"github.com/confiify/confii-go"
"github.com/confiify/confii-go/loader"
"github.com/confiify/confii-go/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",
"secret": "sk-prod-67890",
},
"tls/cert": "-----BEGIN CERTIFICATE-----\n...",
})
// Create resolver with caching
resolver := secret.NewResolver(store,
secret.WithCache(true),
secret.WithCacheTTL(5 * time.Minute),
secret.WithResolverFailOnMissing(true),
)
// Pre-fetch critical secrets
_ = resolver.Prefetch(ctx, []string{"db/password", "api/credentials"})
// Load config
cfg, err := confii.New[any](ctx,
confii.WithLoaders(loader.NewYAML("config.yaml")),
confii.WithEnv("production"),
)
if err != nil {
panic(err)
}
// Wire secret resolver into hook system
cfg.HookProcessor().RegisterGlobalHook(resolver.Hook())
// Access resolved values
dbPass, _ := cfg.Get("database.password")
fmt.Println("DB Password:", dbPass)
// "super-s3cret"
apiKey, _ := cfg.Get("api.key")
fmt.Println("API Key:", apiKey)
// "ak-prod-12345" (extracted via JSON path)
dbURL, _ := cfg.Get("database.url")
fmt.Println("DB URL:", dbURL)
// "postgres://admin:super-s3cret@prod-db:5432/mydb"
// Cache stats
stats := resolver.CacheStats()
fmt.Printf("Cache: %d entries\n", stats["size"])
}
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