Secrets management remains one of the most critical security challenges in cloud-native applications. Despite the importance, I’ve seen countless systems with secrets hardcoded, committed to Git, or stored in plain text environment variables. In this post, I’ll cover the spectrum of secrets management approaches, from basic practices to sophisticated solutions.
The Problem with Traditional Approaches
Let’s start with what NOT to do:
Hardcoded Secrets (Never do this):
// DON'T DO THIS
const (
DatabasePassword = "super_secret_password"
APIKey = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"
)
Secrets in Configuration Files (Also bad):
# config.yaml - DON'T COMMIT THIS
database:
host: db.example.com
username: admin
password: my_password_123
Plain Environment Variables (Better, but insufficient):
export DATABASE_PASSWORD=my_password_123
These approaches fail because:
- Secrets are visible in code repositories and version control
- They’re exposed in process listings and logs
- No audit trail of access
- Difficult to rotate
- No encryption at rest
Kubernetes Secrets: The Baseline
Kubernetes provides a built-in Secret resource:
apiVersion: v1
kind: Secret
metadata:
name: database-credentials
namespace: production
type: Opaque
data:
username: YWRtaW4= # base64 encoded
password: cGFzc3dvcmQxMjM=
Create from literal values:
kubectl create secret generic database-credentials \
--from-literal=username=admin \
--from-literal=password=password123 \
--namespace=production
Consume in a pod:
apiVersion: v1
kind: Pod
metadata:
name: myapp
spec:
containers:
- name: app
image: myapp:latest
env:
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: database-credentials
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: database-credentials
key: password
Or mount as files:
apiVersion: v1
kind: Pod
metadata:
name: myapp
spec:
containers:
- name: app
image: myapp:latest
volumeMounts:
- name: secrets
mountPath: "/etc/secrets"
readOnly: true
volumes:
- name: secrets
secret:
secretName: database-credentials
Limitations:
- Secrets are only base64 encoded, not encrypted in etcd by default
- Anyone with cluster access can read all secrets
- No audit trail
- Managing secrets outside of Git is inconvenient
Enabling Encryption at Rest
Kubernetes supports encrypting secrets in etcd:
# encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <base64-encoded-32-byte-key>
- identity: {}
Configure the API server:
# Add to kube-apiserver flags
--encryption-provider-config=/path/to/encryption-config.yaml
Re-encrypt existing secrets:
kubectl get secrets --all-namespaces -o json | kubectl replace -f -
This protects secrets at rest in etcd but doesn’t solve the management problem.
Sealed Secrets: GitOps-Friendly Encryption
Sealed Secrets allows you to commit encrypted secrets to Git:
# Install controller
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.18.0/controller.yaml
# Install kubeseal CLI
brew install kubeseal
Create a sealed secret:
kubectl create secret generic mysecret \
--from-literal=password=supersecret \
--dry-run=client -o yaml | \
kubeseal -o yaml > sealed-secret.yaml
The sealed secret is safe to commit:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: mysecret
namespace: production
spec:
encryptedData:
password: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq.../encrypted data.../
The controller decrypts it in the cluster:
git add sealed-secret.yaml
git commit -m "Add sealed secret"
git push
# Controller automatically creates the Secret
kubectl get secret mysecret -n production
Advantages:
- GitOps compatible
- Secrets encrypted with cluster-specific keys
- Audit trail through Git
Limitations:
- Key management for the sealing key
- Rotation requires re-sealing
- Secrets still in plain text in the cluster
External Secrets Operator
For integration with external secret stores:
helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets -n external-secrets-system --create-namespace
Define a SecretStore:
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: aws-secretsmanager
namespace: production
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt:
serviceAccountRef:
name: external-secrets-sa
Create an ExternalSecret:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: database-credentials
namespace: production
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secretsmanager
kind: SecretStore
target:
name: database-credentials
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: prod/database/username
- secretKey: password
remoteRef:
key: prod/database/password
The operator syncs secrets from AWS Secrets Manager into Kubernetes.
HashiCorp Vault Integration
Vault provides comprehensive secrets management:
# Install Vault
helm repo add hashicorp https://helm.releases.hashicorp.com
helm install vault hashicorp/vault
Enable Kubernetes authentication:
vault auth enable kubernetes
vault write auth/kubernetes/config \
kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"
Create a policy:
# database-policy.hcl
path "secret/data/database/*" {
capabilities = ["read"]
}
Apply policy:
vault policy write database database-policy.hcl
Create a role:
vault write auth/kubernetes/role/myapp \
bound_service_account_names=myapp \
bound_service_account_namespaces=production \
policies=database \
ttl=24h
Using Vault Agent Injector:
apiVersion: v1
kind: Pod
metadata:
name: myapp
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "myapp"
vault.hashicorp.com/agent-inject-secret-database: "secret/data/database/config"
vault.hashicorp.com/agent-inject-template-database: |
{{- with secret "secret/data/database/config" -}}
export DB_USERNAME="{{ .Data.data.username }}"
export DB_PASSWORD="{{ .Data.data.password }}"
{{- end }}
spec:
serviceAccountName: myapp
containers:
- name: app
image: myapp:latest
command: ["/bin/sh"]
args: ["-c", "source /vault/secrets/database && /app/start"]
Vault injects secrets as files automatically.
Using Vault CSI Provider:
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: vault-database
spec:
provider: vault
parameters:
vaultAddress: "http://vault.vault:8200"
roleName: "myapp"
objects: |
- objectName: "username"
secretPath: "secret/data/database/config"
secretKey: "username"
- objectName: "password"
secretPath: "secret/data/database/config"
secretKey: "password"
---
apiVersion: v1
kind: Pod
metadata:
name: myapp
spec:
serviceAccountName: myapp
containers:
- name: app
image: myapp:latest
volumeMounts:
- name: secrets
mountPath: "/mnt/secrets"
readOnly: true
volumes:
- name: secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: "vault-database"
Application-Level Integration
For programmatic access:
package main
import (
"context"
"fmt"
"log"
vault "github.com/hashicorp/vault/api"
auth "github.com/hashicorp/vault/api/auth/kubernetes"
)
func getSecrets() (map[string]interface{}, error) {
config := vault.DefaultConfig()
config.Address = "http://vault.vault:8200"
client, err := vault.NewClient(config)
if err != nil {
return nil, fmt.Errorf("unable to initialize Vault client: %w", err)
}
// Kubernetes authentication
k8sAuth, err := auth.NewKubernetesAuth(
"myapp",
auth.WithServiceAccountTokenPath("/var/run/secrets/kubernetes.io/serviceaccount/token"),
)
if err != nil {
return nil, fmt.Errorf("unable to initialize k8s auth: %w", err)
}
authInfo, err := client.Auth().Login(context.Background(), k8sAuth)
if err != nil {
return nil, fmt.Errorf("unable to login: %w", err)
}
if authInfo == nil {
return nil, fmt.Errorf("no auth info returned")
}
// Read secret
secret, err := client.KVv2("secret").Get(context.Background(), "database/config")
if err != nil {
return nil, fmt.Errorf("unable to read secret: %w", err)
}
return secret.Data, nil
}
func main() {
secrets, err := getSecrets()
if err != nil {
log.Fatal(err)
}
username := secrets["username"].(string)
password := secrets["password"].(string)
fmt.Printf("Connecting with user: %s\n", username)
}
Cloud Provider Secret Managers
AWS Secrets Manager:
import (
"context"
"encoding/json"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)
func getAWSSecret(secretName string) (map[string]string, error) {
cfg, err := config.LoadDefaultConfig(context.TODO())
if err != nil {
return nil, err
}
svc := secretsmanager.NewFromConfig(cfg)
result, err := svc.GetSecretValue(context.TODO(), &secretsmanager.GetSecretValueInput{
SecretId: aws.String(secretName),
})
if err != nil {
return nil, err
}
var secrets map[string]string
err = json.Unmarshal([]byte(*result.SecretString), &secrets)
return secrets, err
}
Google Secret Manager:
import (
secretmanager "cloud.google.com/go/secretmanager/apiv1"
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
)
func getGCPSecret(projectID, secretID string) (string, error) {
ctx := context.Background()
client, err := secretmanager.NewClient(ctx)
if err != nil {
return "", err
}
defer client.Close()
req := &secretmanagerpb.AccessSecretVersionRequest{
Name: fmt.Sprintf("projects/%s/secrets/%s/versions/latest", projectID, secretID),
}
result, err := client.AccessSecretVersion(ctx, req)
if err != nil {
return "", err
}
return string(result.Payload.Data), nil
}
Secret Rotation
Implement automatic rotation:
# rotate-secrets.py
import boto3
import datetime
def rotate_secret(secret_name):
client = boto3.client('secretsmanager')
# Generate new secret
new_password = generate_secure_password()
# Update database
update_database_password(new_password)
# Update secret
client.update_secret(
SecretId=secret_name,
SecretString=json.dumps({'password': new_password})
)
print(f"Rotated secret {secret_name}")
def lambda_handler(event, context):
# Called by CloudWatch Events daily
rotate_secret('prod/database/password')
Schedule rotation:
apiVersion: batch/v1
kind: CronJob
metadata:
name: rotate-secrets
spec:
schedule: "0 2 * * 0" # Weekly at 2 AM
jobTemplate:
spec:
template:
spec:
containers:
- name: rotate
image: secret-rotator:latest
env:
- name: VAULT_ADDR
value: "http://vault.vault:8200"
restartPolicy: OnFailure
Best Practices
- Principle of Least Privilege: Grant minimal necessary access
- Encryption Everywhere: At rest, in transit, and in use
- Audit Everything: Log all secret access
- Regular Rotation: Automate secret rotation
- No Secrets in Logs: Sanitize before logging
- Separate Secrets by Environment: Different secrets for dev/staging/prod
- Use Short-Lived Credentials: Prefer dynamic secrets over static
Secret Scanning
Prevent secrets from entering Git:
# Install git-secrets
brew install git-secrets
# Configure
git secrets --install
git secrets --register-aws
Pre-commit hook:
#!/bin/bash
# .git/hooks/pre-commit
git secrets --pre_commit_hook -- "$@"
CI/CD scanning:
# GitHub Actions
name: Secret Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: TruffleHog Scan
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEAD
Monitoring and Alerting
Monitor secret access:
apiVersion: v1
kind: ConfigMap
metadata:
name: vault-audit
data:
audit.hcl: |
audit "file" {
file_path = "/vault/logs/audit.log"
}
Alert on suspicious activity:
# vault-monitor.py
def analyze_audit_logs():
suspicious_patterns = [
'failed authentication',
'permission denied',
'secret not found'
]
with open('/vault/logs/audit.log') as f:
for line in f:
entry = json.loads(line)
if any(pattern in entry.get('error', '') for pattern in suspicious_patterns):
send_alert(entry)
Compliance Considerations
For regulated industries:
- Audit Trail: Maintain immutable logs of all secret access
- Encryption Standards: Use FIPS 140-2 validated encryption
- Key Management: Implement proper key lifecycle management
- Access Controls: Enforce multi-factor authentication
- Data Residency: Ensure secrets stay in required regions
Conclusion
Secrets management in cloud-native applications requires a layered approach:
- Never hardcode or commit secrets
- Use Kubernetes Secrets as a minimum baseline
- Enable encryption at rest in etcd
- For GitOps, adopt Sealed Secrets or External Secrets
- For comprehensive management, integrate Vault or cloud provider solutions
- Implement rotation, monitoring, and auditing
- Scan for leaked secrets continuously
The right solution depends on your requirements:
- Small teams: Sealed Secrets + External Secrets Operator
- Enterprise: HashiCorp Vault or cloud provider solutions
- Regulated industries: Vault with extensive auditing
Start with basic practices and evolve as your security requirements mature. The investment in proper secrets management pays dividends in security posture and compliance.