HashiCorp Vault vs AWS Secrets Manager: An In-Depth Comparison + Golang Implementation Examples
Secret management is one of the most often underestimated architecture decisions. Many teams still store database credentials in environment variables, API keys in config files, or OAuth tokens in source code — and only realize the risk after an incident happens. The two solutions most often compared for addressing this problem are HashiCorp Vault and AWS Secrets Manager. Both solve the same problem on the surface, but their philosophies and capabilities differ fundamentally. This article discusses those differences in depth, complete with real Golang implementations, so you can choose correctly according to your context.
Why Secret Management Matters
Before comparing the two, it’s important to understand why traditional approaches fail and what the consequences are.
Storing secrets in environment variables looks safe, but environment variables can leak through application logs, error messages, process dumps, or when transmitted to monitoring tools. The more serious problem: static secrets have no expiry. If a database credential leaks today, it stays valid until someone manually replaces it — which can take days or never happen at all.
Proper secret management must answer these four questions:
- Storage: where is the secret stored and how is it encrypted?
- Access: who is allowed to retrieve this secret and under what conditions?
- Rotation: how often is the secret replaced, and is the process automatic?
- Audit: who has accessed this secret, when, and from where?
HashiCorp Vault and AWS Secrets Manager answer all four questions, but with different approaches.
Basic Philosophy
Understanding the philosophy behind a tool helps you predict its behavior in edge cases and evaluate whether it fits your long-term needs.
HashiCorp Vault
Vault is built on one principle: secrets should be short-lived, dynamic, and tightly controlled. This isn’t just a tagline — it determines Vault’s entire architecture.
Vault acts as a security broker, not just a secret store. When an application needs database credentials, Vault doesn’t return a stored credential — it creates a new credential on demand, hands it to the application, then automatically revokes that credential after its TTL (Time To Live) expires. Credentials with a TTL mean the exposure window is limited: even if a credential leaks, it will expire within a predetermined time.
Beyond dynamic secrets, Vault also applies vendor-agnostic identity-based access. Vault can authenticate workloads using Kubernetes Service Accounts, AWS IAM Roles, Google Cloud Service Accounts, LDAP, OIDC, and dozens of other methods. This makes it suitable for multi-cloud or hybrid architectures.
AWS Secrets Manager
AWS Secrets Manager was built with a different philosophy: managed, simple, and tightly integrated with AWS. Its focus is operational ease, not maximum flexibility.
Secrets Manager is a secure key-value store with rotation capability. The secrets you store are static — they’re not generated on demand. What you can do is configure periodic rotation (for example every 30 days) using Lambda functions that AWS has prepared for popular services like RDS, Redshift, and DocumentDB.
Because it’s fully managed, you don’t need to think about high availability, backups, or patch management. Access is controlled entirely through IAM Policies, which means if you already work with AWS, the learning curve is very gentle.
Feature Comparison
The philosophical differences above have direct implications for the features available. The following table summarizes the main differences:
| Aspect | HashiCorp Vault | AWS Secrets Manager |
|---|---|---|
| Deployment model | Self-hosted or HCP (cloud managed) | Fully managed AWS |
| Dynamic secrets | ✓ Yes — credentials created on demand | ✗ No |
| Leases and TTLs | ✓ Yes — secrets expire automatically | ✗ No |
| Secret rotation | Flexible, can be custom | Built-in for AWS services |
| Policy engine | Granular Vault Policy (HCL) | IAM Policy |
| Multi-cloud / on-prem | ✓ Yes — vendor agnostic | ✗ AWS only |
| Auth methods | 20+ methods (K8s, OIDC, LDAP, cloud IAM) | AWS IAM only |
| Encryption as a Service | ✓ Yes — Transit Secrets Engine | ✗ No |
| Operational overhead | High (needs a team to manage) | Low (fully managed) |
| Cost | Open source free; HCP paid | $0.40/secret/month + $0.05/10K API calls |
| AWS native integration | Needs additional configuration | Very seamless |
One thing worth emphasizing: the “Dynamic secrets” column is the most significant differentiator between the two. This isn’t a minor feature — it’s a paradigm difference. If security is your top priority and you want to minimize the impact of leaked credentials, dynamic secrets is a non-negotiable feature.
Architecture and Workflows
How these two tools work internally affects how you design your system.
HashiCorp Vault Workflow
sequenceDiagram
participant App as Application
participant Vault as HashiCorp Vault
participant DB as Database
App->>Vault: Authenticate (K8s SA / IAM / Token)
Vault-->>App: Vault Token (with TTL)
App->>Vault: Request credentials (database/creds/my-role)
Vault->>DB: CREATE USER vault_xyz WITH PASSWORD '...'
DB-->>Vault: User created successfully
Vault-->>App: username=vault_xyz, password=..., lease_duration=1h
App->>DB: Connect with temporary credentials
Note over Vault,DB: After the TTL expires...
Vault->>DB: DROP USER vault_xyzNote that the credential is never “stored” anywhere — it’s created when needed and removed after expiry. This is true zero-trust.
AWS Secrets Manager Workflow
sequenceDiagram
participant App as Application
participant SM as Secrets Manager
participant DB as Database
participant Lambda as Rotation Lambda
App->>SM: GetSecretValue("prod/myapp/db")
SM-->>App: {"username": "dbuser", "password": "..."}
App->>DB: Connect with static credentials
Note over SM,Lambda: Every 30 days (rotation)...
Lambda->>DB: ALTER USER dbuser PASSWORD '...'
Lambda->>SM: PutSecretValue (new password)The Secrets Manager model is simpler: there’s one long-lived credential, periodically updated in value. The consequence: if a credential leaks, it stays valid until the next rotation happens.
Golang Implementation: HashiCorp Vault
Let’s look at a real implementation of retrieving secrets from Vault using Golang. We’ll cover two scenarios: retrieving a static secret (KV store) and retrieving a dynamic database credential.
Installation
go get github.com/hashicorp/vault/[email protected]
Retrieving a Static Secret from the KV Store
This is the most basic use case — storing and retrieving key-value secrets from the Vault KV Secrets Engine.
package vault
import (
"context"
"fmt"
"log"
vault "github.com/hashicorp/vault/api"
auth "github.com/hashicorp/vault/api/auth/kubernetes"
)
type VaultClient struct {
client *vault.Client
}
// NewVaultClient creates a Vault client with Kubernetes authentication.
// This is the recommended approach for workloads in K8s.
func NewVaultClient(vaultAddr, role string) (*VaultClient, error) {
config := vault.DefaultConfig()
config.Address = vaultAddr
client, err := vault.NewClient(config)
if err != nil {
return nil, fmt.Errorf("failed to create vault client: %w", err)
}
// CORRECT: use Kubernetes auth, not a hardcoded token
k8sAuth, err := auth.NewKubernetesAuth(role)
if err != nil {
return nil, fmt.Errorf("failed to initialize k8s auth: %w", err)
}
authInfo, err := client.Auth().Login(context.Background(), k8sAuth)
if err != nil {
return nil, fmt.Errorf("failed to login to vault: %w", err)
}
if authInfo == nil {
return nil, fmt.Errorf("login succeeded but no auth info returned")
}
return &VaultClient{client: client}, nil
}
type DBSecret struct {
Username string
Password string
}
// GetDBSecret retrieves a database credential from the KV Secrets Engine.
// Path format for KV v2: secret/data/<path>
func (v *VaultClient) GetDBSecret(ctx context.Context, path string) (*DBSecret, error) {
secret, err := v.client.KVv2("secret").Get(ctx, path)
if err != nil {
return nil, fmt.Errorf("failed to read secret at path %s: %w", path, err)
}
if secret == nil || secret.Data == nil {
return nil, fmt.Errorf("secret not found at path: %s", path)
}
username, ok := secret.Data["username"].(string)
if !ok {
return nil, fmt.Errorf("field 'username' not found or not a string")
}
password, ok := secret.Data["password"].(string)
if !ok {
return nil, fmt.Errorf("field 'password' not found or not a string")
}
return &DBSecret{Username: username, Password: password}, nil
}
Retrieving a Dynamic Database Credential
This is the feature AWS Secrets Manager doesn’t have. Vault generates a new database credential every time it’s requested, with a configured TTL.
// GetDynamicDBCredential asks Vault to create a new database credential.
// Every call produces a unique credential with a limited TTL.
func (v *VaultClient) GetDynamicDBCredential(ctx context.Context, role string) (*DBSecret, error) {
// This path is the Database Secrets Engine, not KV
// Format: database/creds/<role-name>
path := fmt.Sprintf("database/creds/%s", role)
secret, err := v.client.Logical().ReadWithContext(ctx, path)
if err != nil {
return nil, fmt.Errorf("failed to generate dynamic credential for role %s: %w", role, err)
}
if secret == nil {
return nil, fmt.Errorf("no credential returned for role: %s", role)
}
username, ok := secret.Data["username"].(string)
if !ok {
return nil, fmt.Errorf("field 'username' missing from response")
}
password, ok := secret.Data["password"].(string)
if !ok {
return nil, fmt.Errorf("field 'password' missing from response")
}
// Record the lease ID for renewal or manual revocation purposes
log.Printf("Dynamic credential created successfully. Lease ID: %s, TTL: %s",
secret.LeaseID, secret.LeaseDuration)
return &DBSecret{Username: username, Password: password}, nil
}
// RenewLease extends a lease's validity before it expires.
// Call this if the application still needs the credential but its TTL is almost up.
func (v *VaultClient) RenewLease(ctx context.Context, leaseID string, increment int) error {
_, err := v.client.Sys().RenewWithContext(ctx, leaseID, increment)
if err != nil {
return fmt.Errorf("failed to renew lease %s: %w", leaseID, err)
}
return nil
}
When using dynamic database credentials, don’t cache credentials without paying attention to their TTL. If you store a credential in memory and the TTL expires, all database connections using that credential will be rejected. Implement a renewal mechanism or create a new credential before the old one expires.
Golang Implementation: AWS Secrets Manager
AWS Secrets Manager uses the AWS SDK v2. Its usage model is simpler because there’s no lease or renewal concept.
Installation
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/secretsmanager
Retrieving a Secret
package secrets
import (
"context"
"encoding/json"
"fmt"
"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"
)
type SecretsManagerClient struct {
client *secretsmanager.Client
}
// NewSecretsManagerClient creates a client with configuration from the environment.
// Authentication is handled automatically through the IAM Role attached to the instance/pod.
func NewSecretsManagerClient(ctx context.Context, region string) (*SecretsManagerClient, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
return &SecretsManagerClient{
client: secretsmanager.NewFromConfig(cfg),
}, nil
}
type DBSecret struct {
Username string `json:"username"`
Password string `json:"password"`
Host string `json:"host"`
Port int `json:"port"`
DBName string `json:"dbname"`
}
// GetDBSecret retrieves and parses a database secret from Secrets Manager.
// secretID can be the secret name or its ARN.
func (s *SecretsManagerClient) GetDBSecret(ctx context.Context, secretID string) (*DBSecret, error) {
input := &secretsmanager.GetSecretValueInput{
SecretId: aws.String(secretID),
}
result, err := s.client.GetSecretValue(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to retrieve secret %s: %w", secretID, err)
}
if result.SecretString == nil {
return nil, fmt.Errorf("secret %s does not contain a string value", secretID)
}
var secret DBSecret
if err := json.Unmarshal([]byte(*result.SecretString), &secret); err != nil {
return nil, fmt.Errorf("failed to parse secret JSON: %w", err)
}
return &secret, nil
}
// GetSecretVersion retrieves a specific version of a secret.
// Useful when rotation is in progress and you need the previous version (AWSPREVIOUS).
func (s *SecretsManagerClient) GetSecretVersion(ctx context.Context, secretID, versionStage string) (*DBSecret, error) {
input := &secretsmanager.GetSecretValueInput{
SecretId: aws.String(secretID),
VersionStage: aws.String(versionStage), // "AWSCURRENT" or "AWSPREVIOUS"
}
result, err := s.client.GetSecretValue(ctx, input)
if err != nil {
return nil, fmt.Errorf("failed to retrieve secret %s (stage: %s): %w", secretID, versionStage, err)
}
var secret DBSecret
if err := json.Unmarshal([]byte(*result.SecretString), &secret); err != nil {
return nil, fmt.Errorf("failed to parse secret JSON: %w", err)
}
return &secret, nil
}
Caching with Automatic Refresh
Calling Secrets Manager on every request is a common anti-pattern — besides adding latency, it also increases API call costs. The solution is caching with a TTL.
package secrets
import (
"context"
"sync"
"time"
)
type CachedSecret struct {
value *DBSecret
fetchedAt time.Time
ttl time.Duration
}
func (c *CachedSecret) isExpired() bool {
return time.Since(c.fetchedAt) > c.ttl
}
type CachingSecretsClient struct {
client *SecretsManagerClient
cache map[string]*CachedSecret
mu sync.RWMutex
ttl time.Duration
}
func NewCachingSecretsClient(client *SecretsManagerClient, cacheTTL time.Duration) *CachingSecretsClient {
return &CachingSecretsClient{
client: client,
cache: make(map[string]*CachedSecret),
ttl: cacheTTL,
}
}
// GetDBSecret returns a secret from the cache if still valid,
// or refetches from Secrets Manager if expired.
func (c *CachingSecretsClient) GetDBSecret(ctx context.Context, secretID string) (*DBSecret, error) {
c.mu.RLock()
cached, exists := c.cache[secretID]
c.mu.RUnlock()
if exists && !cached.isExpired() {
return cached.value, nil
}
// Cache miss or expired — fetch from Secrets Manager
secret, err := c.client.GetDBSecret(ctx, secretID)
if err != nil {
// If the fetch fails but a cache entry still exists (even expired), return the old value
// rather than returning an error and taking the application down
if exists {
return cached.value, nil
}
return nil, err
}
c.mu.Lock()
c.cache[secretID] = &CachedSecret{
value: secret,
fetchedAt: time.Now(),
ttl: c.ttl,
}
c.mu.Unlock()
return secret, nil
}
AWS also provides the official aws/aws-secretsmanager-caching-go library that implements caching with automatic refresh. This library is more battle-tested for production and handles edge cases like in-progress rotation. Consider using it rather than a custom caching implementation.Code Comparison: The Same Feature
For an apple-to-apple comparison, here’s the implementation of an identical function using both tools: creating a database connection pool with a securely retrieved secret.
// ANTI-PATTERN: hardcoding or reading from env vars directly
// ✗ No encryption, no audit trail, no rotation
func connectDBUnsafe() (*sql.DB, error) {
dsn := fmt.Sprintf("postgres://%s:***@localhost/mydb",
os.Getenv("DB_USER"), // ✗ env vars can leak into logs
os.Getenv("DB_PASS"), // ✗ no TTL, valid forever
)
return sql.Open("postgres", dsn)
}
// CORRECT with Vault: dynamic credentials, auto-expire
// ✓ New credential per connection, expires automatically
func connectDBWithVault(vaultClient *VaultClient) (*sql.DB, error) {
cred, err := vaultClient.GetDynamicDBCredential(context.Background(), "my-app-role")
if err != nil {
return nil, fmt.Errorf("failed to get credential from vault: %w", err)
}
dsn := fmt.Sprintf("postgres://%s:***@localhost/mydb", cred.Username, cred.Password)
return sql.Open("postgres", dsn)
}
// CORRECT with Secrets Manager: static credentials with caching
// ✓ Encrypted, has an audit trail, can be rotated periodically
func connectDBWithSecretsManager(smClient *CachingSecretsClient) (*sql.DB, error) {
secret, err := smClient.GetDBSecret(context.Background(), "prod/myapp/db")
if err != nil {
return nil, fmt.Errorf("failed to get credential from secrets manager: %w", err)
}
dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s",
secret.Username, secret.Password,
secret.Host, secret.Port, secret.DBName,
)
return sql.Open("postgres", dsn)
}
Decision Tree — Choose the Right One
Use the following diagram as an initial guide. After finding the endpoint, read the scenario explanations in the next section for fuller context.
flowchart TD
A{Is your infrastructure<br/>100% on AWS?} -- Yes --> B{Need dynamic<br/>secrets or<br/>short-lived credentials?}
A -- No / Hybrid --> C{Do you have an ops team<br/>that can manage<br/>additional infrastructure?}
B -- Yes --> D[HashiCorp Vault<br/>or HCP Vault]
B -- No --> E{Need secrets<br/>outside the<br/>AWS ecosystem?}
E -- No --> F[AWS Secrets Manager]
E -- Yes --> D
C -- Yes --> G{Security or<br/>ease of use as<br/>the top priority?}
C -- No --> H[AWS Secrets Manager<br/>+ other AWS services]
G -- Security / Control --> D
G -- Ease / Speed --> H
style D fill:#e8f5e9,stroke:#43a047
style F fill:#e3f2fd,stroke:#1e88e5
style H fill:#e3f2fd,stroke:#1e88e5Decision Factors
There’s no always-correct choice. Here are the factors to consider concretely.
1. Operational Overhead
Vault requires a serious infrastructure commitment. You need to manage high availability (at least 3 nodes for production), a storage backend (Integrated Storage or Consul), backups, upgrades, and monitoring of Vault itself. If your team has no experience with this, budget several weeks for a proper setup.
HashiCorp Cloud Platform (HCP) Vault offers Vault as a managed service, which removes this operational overhead — but at a significant cost. For most startups, HCP Vault at the Standard level can be more expensive than the entire AWS Secrets Manager cost.
AWS Secrets Manager, on the other hand, has zero operational overhead. You just call its API, and AWS handles the rest.
2. Security Model
Vault enforces zero-trust more strictly. Dynamic secrets mean a leaked credential becomes useless after its TTL expires — the exposure window is very limited. Vault also supports response wrapping (tokens to fetch secrets usable only once) and cubbyholes (private per-token storage).
Secrets Manager uses a perimeter security model — access is controlled at the IAM level. As long as the IAM Role is configured correctly, access is safe. But if a stored credential leaks (for example because the application is compromised), that credential stays valid until the next rotation.
3. Auth Flexibility and Multi-Cloud
This is one of Vault’s biggest advantages. Vault supports more than 20 auth methods: Kubernetes Service Accounts, AWS IAM, GCP Service Accounts, Azure Managed Identity, GitHub, LDAP, OIDC, and many more. This makes it suitable for organizations with workloads across several clouds or on-premise.
Secrets Manager only supports AWS IAM. If you have workloads on GCP that need to access the same secret, you need an additional solution.
4. Capabilities Beyond Secret Storage
Vault is a security platform far broader than just secret storage:
- Transit Secrets Engine: encrypt/decrypt data without storing it in Vault (Encryption as a Service)
- PKI Secrets Engine: an internal certificate authority, issuing and revoking certificates programmatically
- SSH Secrets Engine: signed SSH certificates with short TTLs, replacing static SSH keys
- TOTP Secrets Engine: generate TOTP tokens for MFA
Secrets Manager focuses on one thing: securely storing and rotating secrets. If you need any of the above capabilities, Secrets Manager can’t help.
Scenario-Based Recommendations
AWS-Native Startup, Small Team
Choice: AWS Secrets Manager
Small teams don’t have the bandwidth to manage Vault infrastructure. Secrets Manager provides more than enough security for most startups: encryption at-rest and in-transit, audit trails via CloudTrail, automatic rotation for RDS, and seamless integration with ECS and Lambda. Start here and re-evaluate when needs grow more complex.
Platform Engineering on Kubernetes, Multi-Cloud
Choice: HashiCorp Vault (Self-hosted or HCP)
Kubernetes and Vault are a very natural combination. The Vault Agent Injector or Vault Secrets Operator can automatically inject secrets into pods as files or environment variables, with automatic renewal. If you have workloads across several clouds, Vault provides one control plane for all secrets — no need to manage secrets separately in every cloud.
Enterprise with Strict Regulations (HIPAA, PCI-DSS, SOC2)
Choice: HashiCorp Vault
Regulations like HIPAA and PCI-DSS require granular control, detailed audit trails, and the ability to prove credentials are rotated per policy. Vault provides all of this plus the ability to set very specific policies: who can access which secret, from which IP, at what hours, and how many times a day.
Serverless Applications (Lambda-Heavy)
Choice: AWS Secrets Manager
Lambda integrates natively with Secrets Manager. You can use aws_secretsmanager_secret as an environment variable injected at deployment time, or call its API directly. Secrets Manager API call latency is also low because it’s inside the AWS network. Running a Vault agent on Lambda is impractical overkill.
Migration from On-Premise to Cloud (Hybrid)
Choice: HashiCorp Vault
During the transition, you have workloads on-premise and in the cloud. Vault can run on both and provide one consistent interface. Once the migration is complete, you can consider transitioning to Secrets Manager if it’s a better fit — but Vault can also keep working on AWS without issues.
Anti-Patterns to Avoid
Many teams make the same mistakes when adopting secret management tools. Recognize and avoid these patterns.
// ✗ Anti-pattern 1: Vault used only as a static KV store
// This wastes Vault's potential and is the same as Secrets Manager
// but with more operational overhead
vaultClient.KVv2("secret").Put(ctx, "myapp/db", map[string]interface{}{
"password": "hardcoded-password-that-never-changes",
})
// ✓ Use the Database Secrets Engine for dynamic credentials, or
// at least implement automatic rotation via Vault Agent
// ✗ Anti-pattern 2: Calling Secrets Manager on every request
func handleRequest(w http.ResponseWriter, r *http.Request) {
secret, _ := smClient.GetDBSecret(r.Context(), "prod/myapp/db") // ✗ every request!
db, _ := sql.Open("postgres", buildDSN(secret))
// ...
}
// ✓ Create the database connection pool once at startup,
// or use caching with a reasonable TTL (e.g. 5 minutes)
// ✗ Anti-pattern 3: No error handling when the secret fetch fails
secret, _ := vaultClient.GetDBSecret(ctx, "myapp/db") // ✗ error ignored
db.Username = secret.Username // panic if secret is nil
// ✓ Always handle errors, and consider a fallback strategy
// (e.g. use the expired cached value rather than crashing)
// ✗ Anti-pattern 4: Logging secret values for debugging
log.Printf("Secret retrieved successfully: username=%s password=%s",
secret.Username, secret.Password) // ✗ credentials go into logs!
// ✓ Only log metadata, not the secret values
log.Printf("Secret retrieved successfully for path: %s", secretPath)
Never log secret values, even at the DEBUG level. Logs are usually sent to centralized systems (Datadog, Splunk, CloudWatch Logs) whose access is broader than the application itself. One credential that gets into logs can leak to dozens of people who should never have access.
Summary
- Vault and Secrets Manager aren’t direct competitors — Vault is a security platform, Secrets Manager is a managed secret store. They answer different problems.
- Dynamic secrets are the main differentiator — Vault can generate credentials on demand with a TTL. Secrets Manager stores static credentials with periodic rotation. If the exposure window is critical, Vault is the answer.
- Vault’s operational overhead is real — self-hosting Vault needs a competent team and significant setup time. Consider HCP Vault if you need managed Vault, or start with Secrets Manager if the team is still small.
- Secrets Manager suits AWS-native stacks — its integration with RDS, Lambda, ECS, and IAM is very seamless. If your entire infrastructure is on AWS and you don’t need dynamic secrets, Secrets Manager is the pragmatic choice.
- Always implement caching — don’t call Secrets Manager or Vault on every request. Cache with a reasonable TTL to reduce latency and cost.
- Don’t use Vault merely as a static KV store — if you use Vault but don’t leverage dynamic secrets, leases, or granular auth methods, you bear the overhead without getting the full benefit.
- Never log secret values — audit trails exist to detect unauthorized access, not to record credential values in logs anyone can access.
- Consider hybrid — large organizations often use both: Vault for multi-cloud workloads and dynamic secret needs, Secrets Manager for serverless applications and AWS-native integration.