Secrets Management: HashiCorp Vault vs AWS Secrets Manager
Compare HashiCorp Vault and AWS Secrets Manager for secrets management. Learn when to use each, implementation patterns, and security best practices.
Hardcoded secrets are a security incident waiting to happen. Modern secrets management means centralized storage, automatic rotation, audit logging, and dynamic credentials. This guide compares the two leading solutions and shows how to implement both.
The Problem with Secrets
What counts as a secret:
- Database passwords
- API keys and tokens
- TLS certificates
- SSH keys
- Encryption keys
- OAuth credentials
What happens when you hardcode them:
- Git history exposes them forever
- Rotation requires code deployments
- No audit trail of access
- Lateral movement after compromise
HashiCorp Vault
Vault is a dedicated secrets management platform with advanced features like dynamic secrets, encryption as a service, and identity-based access.
Architecture
┌─────────────────────────────────────────────────┐
│ Vault │
├─────────────────────────────────────────────────┤
│ Auth Methods │ Secrets Engines │ Audit │
│ - Token │ - KV │ - File │
│ - Kubernetes │ - Database │ - Syslog│
│ - AWS IAM │ - AWS │ - Socket│
│ - LDAP │ - PKI │ │
└─────────────────────────────────────────────────┘
Installation (Kubernetes)
# Using Helm
helm repo add hashicorp https://helm.releases.hashicorp.com
helm install vault hashicorp/vault \
--set server.ha.enabled=true \
--set server.ha.replicas=3 \
--set injector.enabled=true
Static Secrets (KV)
# Enable KV secrets engine
vault secrets enable -path=secret kv-v2
# Store a secret
vault kv put secret/myapp/database \
username="admin" \
password="supersecret123"
# Read a secret
vault kv get secret/myapp/database
# Read specific field
vault kv get -field=password secret/myapp/database
Dynamic Database Credentials
# Enable database secrets engine
vault secrets enable database
# Configure PostgreSQL connection
vault write database/config/mydb \
plugin_name=postgresql-database-plugin \
connection_url="postgresql://{{username}}:{{password}}@db.example.com:5432/mydb" \
allowed_roles="myapp-role" \
username="vault-admin" \
password="admin-password"
# Create role with TTL
vault write database/roles/myapp-role \
db_name=mydb \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="1h" \
max_ttl="24h"
# Application requests dynamic credentials
import hvac
client = hvac.Client(url='http://vault:8200', token=os.environ['VAULT_TOKEN'])
creds = client.secrets.database.generate_credentials('myapp-role')
db_user = creds['data']['username']
db_pass = creds['data']['password']
lease_id = creds['lease_id']
lease_duration = creds['lease_duration'] # 3600 seconds
# Credentials automatically expire after TTL
# Renew if needed:
client.sys.renew_lease(lease_id)
Kubernetes Authentication
# Configure Kubernetes auth
vault auth enable kubernetes
vault write auth/kubernetes/config \
kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"
# Create policy
vault policy write myapp - <<EOF
path "secret/data/myapp/*" {
capabilities = ["read"]
}
path "database/creds/myapp-role" {
capabilities = ["read"]
}
EOF
# Create role for service account
vault write auth/kubernetes/role/myapp \
bound_service_account_names=myapp \
bound_service_account_namespaces=production \
policies=myapp \
ttl=1h
Vault Agent Sidecar (Kubernetes)
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
metadata:
annotations:
# Enable Vault Agent injection
vault.hashicorp.com/agent-inject: 'true'
vault.hashicorp.com/role: 'myapp'
# Inject database credentials
vault.hashicorp.com/agent-inject-secret-db-creds: 'database/creds/myapp-role'
vault.hashicorp.com/agent-inject-template-db-creds: |
{{- with secret "database/creds/myapp-role" -}}
export DB_USER="{{ .Data.username }}"
export DB_PASS="{{ .Data.password }}"
{{- end }}
# Inject static secrets
vault.hashicorp.com/agent-inject-secret-config: 'secret/data/myapp/config'
vault.hashicorp.com/agent-inject-template-config: |
{{- with secret "secret/data/myapp/config" -}}
{
"api_key": "{{ .Data.data.api_key }}",
"secret_key": "{{ .Data.data.secret_key }}"
}
{{- end }}
spec:
serviceAccountName: myapp
containers:
- name: myapp
image: myorg/myapp:latest
command: ["/bin/sh", "-c"]
args:
- |
source /vault/secrets/db-creds
/app/start.sh
AWS Secrets Manager
AWS Secrets Manager is a fully managed service tightly integrated with AWS services. Simpler than Vault but less flexible.
Creating Secrets
# Using boto3
import boto3
import json
client = boto3.client('secretsmanager')
# Create secret
client.create_secret(
Name='prod/myapp/database',
SecretString=json.dumps({
'username': 'admin',
'password': 'supersecret123',
'host': 'db.example.com',
'port': 5432
}),
Tags=[
{'Key': 'Environment', 'Value': 'production'},
{'Key': 'Application', 'Value': 'myapp'}
]
)
Terraform
# Create secret
resource "aws_secretsmanager_secret" "database" {
name = "prod/myapp/database"
description = "Database credentials for myapp"
tags = {
Environment = "production"
}
}
resource "aws_secretsmanager_secret_version" "database" {
secret_id = aws_secretsmanager_secret.database.id
secret_string = jsonencode({
username = "admin"
password = random_password.db_password.result
host = aws_db_instance.main.endpoint
port = 5432
})
}
# Auto-rotation for RDS
resource "aws_secretsmanager_secret_rotation" "database" {
secret_id = aws_secretsmanager_secret.database.id
rotation_lambda_arn = aws_lambda_function.rotate_secret.arn
rotation_rules {
automatically_after_days = 30
}
}
Retrieving Secrets
import boto3
import json
from functools import lru_cache
@lru_cache(maxsize=100)
def get_secret(secret_name: str) -> dict:
"""Retrieve and cache secret from Secrets Manager"""
client = boto3.client('secretsmanager')
response = client.get_secret_value(SecretId=secret_name)
return json.loads(response['SecretString'])
# Usage
db_creds = get_secret('prod/myapp/database')
connection = psycopg2.connect(
host=db_creds['host'],
user=db_creds['username'],
password=db_creds['password'],
database='myapp'
)
Lambda Integration
# Lambda function with Secrets Manager
import json
import boto3
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities import parameters
logger = Logger()
# Automatically caches and refreshes
@parameters.secrets_provider()
def handler(event, context):
# Get secret (cached for 5 minutes by default)
db_secret = parameters.get_secret('prod/myapp/database', transform='json')
logger.info(f"Connecting to {db_secret['host']}")
# Use credentials...
ECS/EKS Integration
# ECS Task Definition
{
"containerDefinitions": [
{
"name": "myapp",
"image": "myorg/myapp:latest",
"secrets": [
{
"name": "DB_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:prod/myapp/database:password::"
},
{
"name": "API_KEY",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:prod/myapp/api-keys:primary::"
}
]
}
],
"executionRoleArn": "arn:aws:iam::123456789:role/ecsTaskExecutionRole"
}
# Kubernetes with External Secrets Operator
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: myapp-secrets
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: myapp-secrets
template:
type: Opaque
data:
DB_HOST: "{{ .db_host }}"
DB_USER: "{{ .db_user }}"
DB_PASS: "{{ .db_pass }}"
data:
- secretKey: db_host
remoteRef:
key: prod/myapp/database
property: host
- secretKey: db_user
remoteRef:
key: prod/myapp/database
property: username
- secretKey: db_pass
remoteRef:
key: prod/myapp/database
property: password
Comparison
| Feature | Vault | AWS Secrets Manager |
|---|---|---|
| Hosting | Self-managed or HCP | Fully managed |
| Dynamic secrets | ✅ Full support | ❌ Limited (RDS only) |
| Secret rotation | ✅ Any secret | ⚠️ Limited types |
| Auth methods | Many (K8s, LDAP, AWS, etc.) | IAM only |
| Multi-cloud | ✅ | ❌ AWS only |
| PKI/Certificates | ✅ Built-in | ❌ Use ACM |
| Encryption as service | ✅ Transit engine | ❌ Use KMS |
| Cost | Infrastructure + license | $0.40/secret/month |
| Complexity | High | Low |
When to Use What
Use AWS Secrets Manager when:
- All-in on AWS
- Simple static secrets
- RDS credential rotation
- Minimal operations overhead
- Lambda/ECS native integration
Use HashiCorp Vault when:
- Multi-cloud or hybrid
- Dynamic credentials needed
- PKI/certificate management
- Complex access policies
- Encryption as a service
- Compliance requirements (SOC2, HIPAA)
Security Best Practices
1. Never Log Secrets
# ❌ Don't do this
logger.info(f"Connecting with password: {password}")
# ✅ Do this
logger.info(f"Connecting to {host} as {username}")
2. Use Short TTLs
# Vault: Short-lived credentials
vault write database/roles/myapp-role \
default_ttl="1h" \
max_ttl="24h"
3. Audit Everything
# Vault audit logging
vault audit enable file file_path=/var/log/vault/audit.log
4. Rotate Regularly
# AWS: Automatic rotation
rotation_rules {
automatically_after_days = 30
}
5. Least Privilege Access
# Vault policy - read only specific path
path "secret/data/myapp/*" {
capabilities = ["read"]
}
# Deny list capability
path "secret/metadata/*" {
capabilities = ["deny"]
}
6. Use Environment-Specific Secrets
secret/
├── dev/
│ └── myapp/
├── staging/
│ └── myapp/
└── prod/
└── myapp/
Key Takeaways
- Never hardcode secrets — use a secrets manager
- Dynamic credentials > static — they can’t be stolen if they expire
- Rotate regularly — assume compromise, minimize blast radius
- Audit access — know who accessed what and when
- Least privilege — applications only get what they need
- Start simple — AWS Secrets Manager is fine for most cases
- Graduate to Vault — when you need dynamic secrets or multi-cloud
“The best secret is one that doesn’t exist. The second best is one that expires before an attacker can use it.”