Secret Providers
This guide covers the SecretProvider interface, implementing custom providers, and best practices for secret management.
The SecretProvider Interface
type SecretProvider interface {
Get(ctx context.Context, key string) (string, error)
}
Providers are simple: given a key, return a value. The context supports timeouts and cancellation for network-based providers.
Built-in Providers
fig includes providers for common secret stores:
| Package | Backend | Key Format |
|---|---|---|
fig/vault | HashiCorp Vault | path/to/secret:field |
fig/awssm | AWS Secrets Manager | secret-name:json-field |
fig/gcpsm | GCP Secret Manager | secret-name:json-field |
See the integration docs for Vault, AWS Secrets Manager, and GCP Secret Manager for detailed documentation.
Using a Provider
Pass the provider to Load:
provider, err := vault.New()
if err != nil {
log.Fatal(err)
}
var cfg Config
if err := fig.Load(&cfg, provider); err != nil {
log.Fatal(err)
}
Only one provider is accepted. For multiple backends, see Composing Providers.
Implementing a Custom Provider
Basic Implementation
type EnvSecretProvider struct {
prefix string
}
func NewEnvSecretProvider(prefix string) *EnvSecretProvider {
return &EnvSecretProvider{prefix: prefix}
}
func (p *EnvSecretProvider) Get(ctx context.Context, key string) (string, error) {
// Convert "db/password" to "SECRET_DB_PASSWORD"
envKey := p.prefix + strings.ToUpper(strings.ReplaceAll(key, "/", "_"))
val := os.Getenv(envKey)
if val == "" {
return "", fig.ErrSecretNotFound
}
return val, nil
}
With Caching
type CachingProvider struct {
inner fig.SecretProvider
cache map[string]string
mu sync.RWMutex
ttl time.Duration
}
func NewCachingProvider(inner fig.SecretProvider, ttl time.Duration) *CachingProvider {
return &CachingProvider{
inner: inner,
cache: make(map[string]string),
ttl: ttl,
}
}
func (p *CachingProvider) Get(ctx context.Context, key string) (string, error) {
p.mu.RLock()
if val, ok := p.cache[key]; ok {
p.mu.RUnlock()
return val, nil
}
p.mu.RUnlock()
val, err := p.inner.Get(ctx, key)
if err != nil {
return "", err
}
p.mu.Lock()
p.cache[key] = val
p.mu.Unlock()
return val, nil
}
With Retry Logic
type RetryProvider struct {
inner fig.SecretProvider
maxRetries int
backoff time.Duration
}
func (p *RetryProvider) Get(ctx context.Context, key string) (string, error) {
var lastErr error
for i := 0; i <= p.maxRetries; i++ {
val, err := p.inner.Get(ctx, key)
if err == nil {
return val, nil
}
// Don't retry for "not found"
if err == fig.ErrSecretNotFound {
return "", err
}
lastErr = err
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(p.backoff * time.Duration(i+1)):
}
}
return "", fmt.Errorf("after %d retries: %w", p.maxRetries, lastErr)
}
Error Handling
ErrSecretNotFound
Return fig.ErrSecretNotFound when the secret doesn't exist:
func (p *MyProvider) Get(ctx context.Context, key string) (string, error) {
val, err := p.client.Fetch(key)
if errors.Is(err, client.ErrNotFound) {
return "", fig.ErrSecretNotFound // fig continues to env/default
}
if err != nil {
return "", err // fig fails with this error
}
return val, nil
}
Why this matters: ErrSecretNotFound tells fig to continue to the next source. Other errors cause Load to fail immediately.
Propagating Context Errors
Respect context cancellation:
func (p *MyProvider) Get(ctx context.Context, key string) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
// ... fetch secret
}
Key Format Conventions
Path-Based Keys
Use forward slashes for hierarchical keys:
Password string `secret:"database/credentials/password"`
APIKey string `secret:"services/stripe/api-key"`
Field Extraction
Use colons to extract fields from structured secrets:
// For JSON secrets like {"username": "admin", "password": "secret"}
Username string `secret:"database/credentials:username"`
Password string `secret:"database/credentials:password"`
Provider-Specific Prefixes
Some providers support prefixes to route to different backends:
// Custom provider that routes based on prefix
VaultSecret string `secret:"vault:db/password"`
AWSSecret string `secret:"aws:api-key"`
Composing Providers
Multi-Backend Provider
Route to different providers based on key prefix:
type MultiProvider struct {
providers map[string]fig.SecretProvider
fallback fig.SecretProvider
}
func (m *MultiProvider) Get(ctx context.Context, key string) (string, error) {
prefix, actualKey, found := strings.Cut(key, ":")
if found {
if p, ok := m.providers[prefix]; ok {
return p.Get(ctx, actualKey)
}
}
if m.fallback != nil {
return m.fallback.Get(ctx, key)
}
return "", fig.ErrSecretNotFound
}
Usage:
provider := &MultiProvider{
providers: map[string]fig.SecretProvider{
"vault": vaultProvider,
"aws": awsProvider,
},
}
type Config struct {
DBPassword string `secret:"vault:database/password"`
StripeKey string `secret:"aws:stripe-api-key"`
}
Fallback Chain
Try multiple providers in order:
type ChainProvider struct {
providers []fig.SecretProvider
}
func (c *ChainProvider) Get(ctx context.Context, key string) (string, error) {
for _, p := range c.providers {
val, err := p.Get(ctx, key)
if err == nil {
return val, nil
}
if err != fig.ErrSecretNotFound {
return "", err // Propagate real errors
}
}
return "", fig.ErrSecretNotFound
}
Security Considerations
Logging
Never log secret values:
func (p *MyProvider) Get(ctx context.Context, key string) (string, error) {
log.Printf("fetching secret: %s", key) // OK: log key
val, err := p.fetch(key)
// log.Printf("got value: %s", val) // BAD: never log values
return val, err
}
Memory
Consider clearing secrets from memory when no longer needed:
type Config struct {
Password string `secret:"db/password"`
}
func (c *Config) Clear() {
c.Password = ""
}
Context Timeouts
Always use context for network operations:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := fig.LoadContext(ctx, &cfg, provider); err != nil {
log.Fatal(err)
}
Next Steps
- Vault Integration — HashiCorp Vault setup
- AWS Secrets Manager — AWS integration
- GCP Secret Manager — GCP integration
- Testing — testing with mock providers