AWS Secrets Manager Integration
Secure application secrets with AWS Secrets Manager including automatic rotation, caching, and Node.js SDK integration patterns
AWS Secrets Manager Integration
Overview
AWS Secrets Manager is a managed service for storing, retrieving, and rotating application secrets like database credentials, API keys, and OAuth tokens. It eliminates the practice of hardcoding credentials in source code or stuffing them into environment variables that get committed to version control. If you are running anything in production on AWS, Secrets Manager should be your default choice for credential management.
Prerequisites
- An AWS account with IAM permissions for Secrets Manager
- Node.js v16 or later installed
- AWS CLI v2 configured with credentials (
aws configure) - Basic familiarity with AWS SDK for JavaScript v3
- An Express.js application (for the complete example)
Install the required packages:
npm install @aws-sdk/client-secrets-manager @aws-sdk/client-lambda express pg
Creating and Managing Secrets
Secrets Manager stores secrets as key-value pairs or plaintext strings. The most common pattern is a JSON object containing multiple related values. You can create secrets through the AWS Console, CLI, or SDK.
Creating a Secret via CLI
aws secretsmanager create-secret \
--name production/myapp/database \
--description "Production database credentials" \
--secret-string '{"username":"app_user","password":"S3cur3P@ss!","host":"mydb.cluster-abc123.us-east-1.rds.amazonaws.com","port":5432,"dbname":"myapp_prod"}'
I use a hierarchical naming convention with forward slashes: environment/application/purpose. This maps cleanly to IAM policy conditions and makes it trivial to grant access by environment or application.
Creating a Secret with Node.js
var AWS = require("@aws-sdk/client-secrets-manager");
var client = new AWS.SecretsManagerClient({ region: "us-east-1" });
function createSecret(name, secretValue) {
var command = new AWS.CreateSecretCommand({
Name: name,
Description: "Application database credentials",
SecretString: JSON.stringify(secretValue),
Tags: [
{ Key: "Environment", Value: "production" },
{ Key: "Application", Value: "myapp" }
]
});
return client.send(command);
}
var credentials = {
username: "app_user",
password: "S3cur3P@ss!",
host: "mydb.cluster-abc123.us-east-1.rds.amazonaws.com",
port: 5432,
dbname: "myapp_prod"
};
createSecret("production/myapp/database", credentials)
.then(function(result) {
console.log("Secret created:", result.ARN);
})
.catch(function(err) {
console.error("Failed to create secret:", err.message);
});
Updating a Secret
function updateSecret(name, newValue) {
var command = new AWS.PutSecretValueCommand({
SecretId: name,
SecretString: JSON.stringify(newValue)
});
return client.send(command);
}
Every time you call PutSecretValue, Secrets Manager creates a new version. The previous version is still accessible through version IDs and staging labels. This is important for rotation, which I will cover shortly.
Retrieving Secrets with Node.js SDK
Retrieving secrets is the operation you will perform most frequently. Here is a straightforward retrieval function:
var AWS = require("@aws-sdk/client-secrets-manager");
var client = new AWS.SecretsManagerClient({ region: "us-east-1" });
function getSecret(secretName) {
var command = new AWS.GetSecretValueCommand({
SecretId: secretName
});
return client.send(command).then(function(response) {
if (response.SecretString) {
return JSON.parse(response.SecretString);
}
// Binary secrets are returned as a Buffer
var buff = Buffer.from(response.SecretBinary, "base64");
return buff.toString("utf-8");
});
}
getSecret("production/myapp/database").then(function(secret) {
console.log("Host:", secret.host);
console.log("Username:", secret.username);
});
This works but has a problem: every call hits the Secrets Manager API. At $0.05 per 10,000 API calls plus the latency of a network round trip on every request, this adds up fast in a high-traffic application.
Caching Secrets for Performance
The official AWS recommendation is to cache secrets locally and refresh them periodically. AWS provides a caching client for some languages, but for Node.js you are better off building your own. It is simple and gives you full control over cache invalidation.
var AWS = require("@aws-sdk/client-secrets-manager");
var client = new AWS.SecretsManagerClient({ region: "us-east-1" });
var cache = {};
var DEFAULT_TTL = 300000; // 5 minutes in milliseconds
function getCachedSecret(secretName, ttl) {
var cacheTtl = ttl || DEFAULT_TTL;
var now = Date.now();
var cached = cache[secretName];
if (cached && (now - cached.timestamp) < cacheTtl) {
return Promise.resolve(cached.value);
}
var command = new AWS.GetSecretValueCommand({
SecretId: secretName
});
return client.send(command).then(function(response) {
var value = JSON.parse(response.SecretString);
cache[secretName] = {
value: value,
timestamp: now,
versionId: response.VersionId
};
return value;
});
}
function invalidateCache(secretName) {
if (secretName) {
delete cache[secretName];
} else {
cache = {};
}
}
module.exports = {
getSecret: getCachedSecret,
invalidateCache: invalidateCache
};
A five-minute TTL is a reasonable default. It means that after a secret rotates, your application will pick up the new value within five minutes. For most applications that is acceptable. If you need tighter control, reduce the TTL or use CloudWatch Events to trigger cache invalidation.
Secret Rotation with Lambda
Automatic rotation is the feature that separates Secrets Manager from simpler alternatives. You configure a Lambda function that Secrets Manager invokes on a schedule to generate new credentials, update the target service, and store the new values.
How Rotation Works
Rotation follows a four-step process, each triggered by Secrets Manager calling your Lambda with a different Step value:
- createSecret - Generate a new credential and store it as
AWSPENDING - setSecret - Apply the new credential to the target service (e.g., update the database password)
- testSecret - Verify the new credential works
- finishSecret - Move the
AWSCURRENTlabel to the new version
Rotation Lambda for RDS
var AWS = require("@aws-sdk/client-secrets-manager");
var crypto = require("crypto");
var pg = require("pg");
var secretsClient = new AWS.SecretsManagerClient({ region: "us-east-1" });
exports.handler = function(event, context, callback) {
var secretId = event.SecretId;
var step = event.Step;
var token = event.ClientRequestToken;
console.log("Rotation step:", step, "for secret:", secretId);
var stepHandlers = {
createSecret: createSecret,
setSecret: setSecret,
testSecret: testSecret,
finishSecret: finishSecret
};
var handler = stepHandlers[step];
if (!handler) {
return callback(new Error("Unknown step: " + step));
}
handler(secretId, token)
.then(function() { callback(null); })
.catch(function(err) { callback(err); });
};
function createSecret(secretId, token) {
// Get current secret
var getCommand = new AWS.GetSecretValueCommand({
SecretId: secretId,
VersionStage: "AWSCURRENT"
});
return secretsClient.send(getCommand).then(function(current) {
var currentSecret = JSON.parse(current.SecretString);
// Generate new password
var newPassword = crypto.randomBytes(32).toString("base64").slice(0, 40);
currentSecret.password = newPassword;
// Store as pending
var putCommand = new AWS.PutSecretValueCommand({
SecretId: secretId,
ClientRequestToken: token,
SecretString: JSON.stringify(currentSecret),
VersionStages: ["AWSPENDING"]
});
return secretsClient.send(putCommand);
});
}
function setSecret(secretId, token) {
// Get the pending secret
var getCommand = new AWS.GetSecretValueCommand({
SecretId: secretId,
VersionStage: "AWSPENDING",
VersionId: token
});
return secretsClient.send(getCommand).then(function(pending) {
var secret = JSON.parse(pending.SecretString);
// Connect with current (admin) credentials and update password
var dbClient = new pg.Client({
host: secret.host,
port: secret.port,
database: secret.dbname,
user: secret.username,
password: secret.password
});
return dbClient.connect().then(function() {
var query = "ALTER USER " + secret.username + " WITH PASSWORD '" + secret.password + "'";
return dbClient.query(query);
}).then(function() {
return dbClient.end();
});
});
}
function testSecret(secretId, token) {
var getCommand = new AWS.GetSecretValueCommand({
SecretId: secretId,
VersionStage: "AWSPENDING",
VersionId: token
});
return secretsClient.send(getCommand).then(function(pending) {
var secret = JSON.parse(pending.SecretString);
var dbClient = new pg.Client({
host: secret.host,
port: secret.port,
database: secret.dbname,
user: secret.username,
password: secret.password
});
return dbClient.connect().then(function() {
return dbClient.query("SELECT 1");
}).then(function() {
return dbClient.end();
});
});
}
function finishSecret(secretId, token) {
// Get current version
var describeCommand = new AWS.DescribeSecretCommand({
SecretId: secretId
});
return secretsClient.send(describeCommand).then(function(metadata) {
var versions = metadata.VersionIdsToStages;
var currentVersionId;
Object.keys(versions).forEach(function(versionId) {
if (versions[versionId].indexOf("AWSCURRENT") !== -1) {
currentVersionId = versionId;
}
});
// Move AWSCURRENT to new version, AWSPREVIOUS to old
var updateCommand = new AWS.UpdateSecretVersionStageCommand({
SecretId: secretId,
VersionStage: "AWSCURRENT",
MoveToVersionId: token,
RemoveFromVersionId: currentVersionId
});
return secretsClient.send(updateCommand);
});
}
Enabling Rotation via CLI
aws secretsmanager rotate-secret \
--secret-id production/myapp/database \
--rotation-lambda-arn arn:aws:lambda:us-east-1:123456789012:function:RotateMyAppSecret \
--rotation-rules '{"AutomaticallyAfterDays": 30}'
Thirty days is a common rotation interval. For highly sensitive credentials, consider seven days. The tradeoff is that more frequent rotation increases the chance of transient connection failures during the rotation window.
RDS Credential Rotation
For RDS databases, AWS provides pre-built rotation Lambda functions. This is the easiest path and what I recommend for most teams.
# Enable rotation with the built-in RDS single-user rotation template
aws secretsmanager rotate-secret \
--secret-id production/myapp/rds-credentials \
--rotation-lambda-arn arn:aws:lambda:us-east-1:123456789012:function:SecretsManagerRDSPostgreSQLRotation \
--rotation-rules '{"ScheduleExpression": "rate(30 days)"}'
The single-user rotation strategy updates the password for the same user. The multi-user strategy alternates between two users so that one set of credentials is always valid. Use multi-user if you cannot tolerate any connection failures during rotation.
Multi-User Rotation Secret Format
For multi-user rotation, the secret must include a reference to a master secret:
{
"username": "app_user",
"password": "currentPassword123",
"host": "mydb.cluster-abc123.us-east-1.rds.amazonaws.com",
"port": 5432,
"dbname": "myapp_prod",
"masterarn": "arn:aws:secretsmanager:us-east-1:123456789012:secret:production/myapp/rds-master"
}
Secrets Manager vs Parameter Store
This is a question I get asked constantly. Here is my honest take:
| Feature | Secrets Manager | Parameter Store (SecureString) |
|---|---|---|
| Cost | $0.40/secret/month + $0.05/10K API calls | Free tier for standard params; $0.05/10K API calls for advanced |
| Rotation | Built-in automatic rotation | Manual only |
| Cross-account | Native support via resource policies | Requires IAM roles and custom solutions |
| Binary secrets | Yes | No |
| Versioning | Automatic with staging labels | Version history but no staging labels |
| Max size | 64 KB | 8 KB (advanced tier) |
| CloudFormation | Dynamic references supported | Dynamic references supported |
My recommendation: Use Secrets Manager for anything that needs rotation (database passwords, API keys with expiry) and for cross-account scenarios. Use Parameter Store for static configuration values, feature flags, and non-sensitive config. Do not overthink this. The cost difference is negligible at scale.
Secret Versioning and Staging Labels
Every time you update a secret, Secrets Manager creates a new version with a unique version ID. Staging labels control which version is returned by default:
- AWSCURRENT - The active version returned by default
- AWSPREVIOUS - The previous version (automatically assigned during rotation)
- AWSPENDING - The version being prepared during rotation
You can also create custom staging labels:
function getSecretByStage(secretName, stage) {
var command = new AWS.GetSecretValueCommand({
SecretId: secretName,
VersionStage: stage || "AWSCURRENT"
});
return client.send(command).then(function(response) {
return {
value: JSON.parse(response.SecretString),
versionId: response.VersionId,
versionStages: response.VersionStages
};
});
}
// Retrieve the previous version (useful for rollback)
getSecretByStage("production/myapp/database", "AWSPREVIOUS")
.then(function(result) {
console.log("Previous version:", result.versionId);
});
You can add custom staging labels to implement blue-green deployment patterns for secrets:
aws secretsmanager update-secret-version-stage \
--secret-id production/myapp/database \
--version-stage BLUE \
--move-to-version-id "abc-123-def-456"
Cross-Account Secret Sharing
Sharing secrets across AWS accounts is a common requirement in organizations with multiple accounts for different environments or teams. Secrets Manager supports this through resource-based policies.
Granting Cross-Account Access
aws secretsmanager put-resource-policy \
--secret-id production/myapp/shared-api-key \
--resource-policy '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::987654321098:role/ConsumerAppRole"
},
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Resource": "*"
}
]
}'
Retrieving a Cross-Account Secret
From the consuming account, reference the secret by its full ARN:
var client = new AWS.SecretsManagerClient({ region: "us-east-1" });
function getCrossAccountSecret() {
var command = new AWS.GetSecretValueCommand({
SecretId: "arn:aws:secretsmanager:us-east-1:123456789012:secret:production/myapp/shared-api-key-AbCdEf"
});
return client.send(command).then(function(response) {
return JSON.parse(response.SecretString);
});
}
The consuming account's IAM role also needs secretsmanager:GetSecretValue permission. Both the resource policy on the secret and the identity policy on the role must allow the action. This is the standard AWS dual-authorization model.
If the secret is encrypted with a customer-managed KMS key, you must also grant kms:Decrypt permission to the consuming account on that KMS key.
CloudFormation Integration
You can reference Secrets Manager secrets directly in CloudFormation templates using dynamic references. This keeps credentials out of your templates entirely.
AWSTemplateFormatVersion: '2010-09-09'
Description: Application stack with Secrets Manager integration
Resources:
AppSecret:
Type: AWS::SecretsManager::Secret
Properties:
Name: production/myapp/database
Description: Database credentials for MyApp
GenerateSecretString:
SecretStringTemplate: '{"username": "app_user"}'
GenerateStringKey: password
PasswordLength: 40
ExcludeCharacters: '"@/\'
AppDatabase:
Type: AWS::RDS::DBInstance
Properties:
DBInstanceClass: db.t3.medium
Engine: postgres
MasterUsername: !Sub '{{resolve:secretsmanager:${AppSecret}:SecretString:username}}'
MasterUserPassword: !Sub '{{resolve:secretsmanager:${AppSecret}:SecretString:password}}'
DBName: myapp_prod
RotationSchedule:
Type: AWS::SecretsManager::RotationSchedule
Properties:
SecretId: !Ref AppSecret
RotationLambdaARN: !GetAtt RotationFunction.Arn
RotationRules:
AutomaticallyAfterDays: 30
SecretTargetAttachment:
Type: AWS::SecretsManager::SecretTargetAttachment
Properties:
SecretId: !Ref AppSecret
TargetId: !Ref AppDatabase
TargetType: AWS::RDS::DBInstance
The SecretTargetAttachment resource is important. It automatically updates the secret with the database endpoint, port, and engine information after the RDS instance is created. Without it, you would need to manually add connection details to the secret.
Lambda Layer for Secret Caching
For Lambda functions that need secrets, packaging a caching layer avoids repeated API calls across invocations within the same execution environment.
// layer/nodejs/secretsCache.js
var AWS = require("@aws-sdk/client-secrets-manager");
var client = new AWS.SecretsManagerClient({
region: process.env.AWS_REGION
});
var cache = {};
function getSecret(secretName) {
if (cache[secretName]) {
return Promise.resolve(cache[secretName]);
}
var command = new AWS.GetSecretValueCommand({
SecretId: secretName
});
return client.send(command).then(function(response) {
var value = JSON.parse(response.SecretString);
cache[secretName] = value;
return value;
});
}
function clearCache() {
cache = {};
}
module.exports = {
getSecret: getSecret,
clearCache: clearCache
};
Lambda execution environments persist between invocations, so the in-memory cache survives across requests. The cache resets when the execution environment is recycled, which naturally provides periodic refreshes. For Lambda, this is usually sufficient. If you need explicit TTL-based expiry in Lambda, use the same pattern from the caching section above.
Deploy the layer:
cd layer
zip -r secrets-cache-layer.zip nodejs/
aws lambda publish-layer-version \
--layer-name secrets-cache \
--zip-file fileb://secrets-cache-layer.zip \
--compatible-runtimes nodejs18.x nodejs20.x
Monitoring Secret Access with CloudTrail
Every Secrets Manager API call is logged in CloudTrail. This is critical for auditing who accessed which secrets and when.
Key Events to Monitor
GetSecretValue- Someone or something retrieved a secretPutSecretValue- A secret was updatedDeleteSecret- A secret was deletedRotationStarted- Automatic rotation beganRotationSucceeded/RotationFailed- Rotation completed or failed
CloudWatch Alarm for Unauthorized Access
aws cloudwatch put-metric-alarm \
--alarm-name "SecretsManager-UnauthorizedAccess" \
--metric-name "Errors" \
--namespace "AWS/SecretsManager" \
--statistic "Sum" \
--period 300 \
--threshold 5 \
--comparison-operator "GreaterThanThreshold" \
--evaluation-periods 1 \
--alarm-actions "arn:aws:sns:us-east-1:123456789012:security-alerts"
CloudTrail Log Query with Athena
SELECT
eventtime,
useridentity.arn AS caller,
requestparameters AS params,
errorcode,
errormessage
FROM cloudtrail_logs
WHERE eventsource = 'secretsmanager.amazonaws.com'
AND eventname = 'GetSecretValue'
AND errorcode IS NOT NULL
ORDER BY eventtime DESC
LIMIT 50;
This query surfaces failed secret retrieval attempts, which could indicate misconfigured applications or unauthorized access attempts.
Complete Working Example
Here is a production-ready Express application that demonstrates Secrets Manager integration with caching, rotation handling, and graceful fallback.
// app.js
var express = require("express");
var AWS = require("@aws-sdk/client-secrets-manager");
var pg = require("pg");
var app = express();
var PORT = process.env.PORT || 3000;
// --- Secrets Manager Client ---
var secretsClient = new AWS.SecretsManagerClient({
region: process.env.AWS_REGION || "us-east-1"
});
// --- Secret Cache ---
var secretCache = {};
var CACHE_TTL = 300000; // 5 minutes
function getCachedSecret(secretName) {
var now = Date.now();
var cached = secretCache[secretName];
if (cached && (now - cached.timestamp) < CACHE_TTL) {
return Promise.resolve(cached.value);
}
var command = new AWS.GetSecretValueCommand({
SecretId: secretName
});
return secretsClient.send(command)
.then(function(response) {
var value = JSON.parse(response.SecretString);
secretCache[secretName] = {
value: value,
timestamp: now,
versionId: response.VersionId
};
return value;
})
.catch(function(err) {
// If we have a stale cached value, return it during API failures
if (cached) {
console.warn(
"Secrets Manager API failed, using stale cache for:",
secretName,
"Error:",
err.message
);
return cached.value;
}
throw err;
});
}
function invalidateSecret(secretName) {
delete secretCache[secretName];
}
// --- Database Connection Pool with Rotation Handling ---
var pool = null;
var DB_SECRET_NAME = process.env.DB_SECRET_NAME || "production/myapp/database";
function getDbPool() {
if (pool) {
return Promise.resolve(pool);
}
return getCachedSecret(DB_SECRET_NAME).then(function(dbSecret) {
pool = new pg.Pool({
host: dbSecret.host,
port: dbSecret.port,
database: dbSecret.dbname,
user: dbSecret.username,
password: dbSecret.password,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
});
pool.on("error", function(err) {
console.error("Unexpected pool error:", err.message);
// If authentication fails, the password may have rotated
if (err.message.indexOf("authentication") !== -1 ||
err.message.indexOf("password") !== -1) {
console.log("Possible credential rotation detected. Refreshing pool.");
invalidateSecret(DB_SECRET_NAME);
pool.end();
pool = null;
}
});
return pool;
});
}
function queryWithRetry(sql, params, retries) {
var maxRetries = retries || 1;
return getDbPool()
.then(function(dbPool) {
return dbPool.query(sql, params);
})
.catch(function(err) {
// Retry once on auth failure (credential rotation)
if (maxRetries > 0 && (
err.code === "28P01" || // PostgreSQL invalid password
err.message.indexOf("authentication") !== -1
)) {
console.log("Auth failure detected. Invalidating cache and retrying.");
invalidateSecret(DB_SECRET_NAME);
pool.end();
pool = null;
return queryWithRetry(sql, params, maxRetries - 1);
}
throw err;
});
}
// --- API Key Retrieval ---
var API_KEYS_SECRET = process.env.API_KEYS_SECRET || "production/myapp/api-keys";
function getApiKey(keyName) {
return getCachedSecret(API_KEYS_SECRET).then(function(keys) {
if (!keys[keyName]) {
throw new Error("API key not found: " + keyName);
}
return keys[keyName];
});
}
// --- Express Routes ---
app.get("/health", function(req, res) {
queryWithRetry("SELECT 1 AS ok")
.then(function(result) {
res.json({ status: "healthy", db: "connected" });
})
.catch(function(err) {
res.status(503).json({
status: "unhealthy",
db: "disconnected",
error: err.message
});
});
});
app.get("/users", function(req, res) {
queryWithRetry("SELECT id, name, email FROM users LIMIT 50")
.then(function(result) {
res.json(result.rows);
})
.catch(function(err) {
console.error("Query failed:", err.message);
res.status(500).json({ error: "Internal server error" });
});
});
app.get("/external-data", function(req, res) {
getApiKey("stripe")
.then(function(apiKey) {
// Use the API key for an external service call
res.json({
message: "API key retrieved successfully",
keyPrefix: apiKey.substring(0, 7) + "..."
});
})
.catch(function(err) {
console.error("Failed to get API key:", err.message);
res.status(500).json({ error: "Configuration error" });
});
});
// --- Graceful Shutdown ---
process.on("SIGTERM", function() {
console.log("SIGTERM received. Shutting down gracefully.");
if (pool) {
pool.end().then(function() {
console.log("Database pool closed.");
process.exit(0);
});
} else {
process.exit(0);
}
});
// --- Start Server ---
app.listen(PORT, function() {
console.log("Server running on port", PORT);
// Pre-warm the secret cache on startup
getCachedSecret(DB_SECRET_NAME)
.then(function() {
console.log("Database secret cached successfully.");
})
.catch(function(err) {
console.error("WARNING: Failed to pre-cache database secret:", err.message);
console.error("Application will retry on first request.");
});
});
Key design decisions in this example:
- Stale cache fallback - If the Secrets Manager API is unavailable, we return the last known value rather than failing. This prevents API outages from cascading into application failures.
- Rotation-aware retry - When a database query fails with an authentication error, we invalidate the cached credentials, rebuild the connection pool, and retry once. This handles the brief window during rotation when cached credentials may be stale.
- Pre-warming - The secret cache is populated at startup so the first request does not incur the latency of a Secrets Manager API call.
- Graceful shutdown - The connection pool is drained cleanly on SIGTERM, which is essential for container environments.
Common Issues and Troubleshooting
1. AccessDeniedException on GetSecretValue
AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/MyAppRole/i-0abc123
is not authorized to perform: secretsmanager:GetSecretValue on resource:
arn:aws:secretsmanager:us-east-1:123456789012:secret:production/myapp/database-AbCdEf
This means your IAM role lacks the secretsmanager:GetSecretValue permission. Add it to the role's policy:
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:production/myapp/*"
}
If the secret is encrypted with a customer-managed KMS key, you also need kms:Decrypt on that key.
2. ResourceNotFoundException During Rotation
ResourceNotFoundException: Secrets Manager can't find the specified secret.
This typically occurs when the rotation Lambda cannot find the pending secret version. Verify that the ClientRequestToken in PutSecretValue matches the token passed to the Lambda. Also confirm the Lambda has network access to the Secrets Manager endpoint, especially if it runs inside a VPC. You need either a NAT Gateway or a VPC endpoint for Secrets Manager.
3. Connection Reset After Rotation
Error: Connection terminated unexpectedly
error: password authentication failed for user "app_user"
Your application is using cached credentials that were invalidated by rotation. Implement the retry-on-auth-failure pattern shown in the complete example above. Also ensure your cache TTL is shorter than your rotation window.
4. Rotation Lambda Timeout
Task timed out after 30.00 seconds
The default Lambda timeout of 30 seconds is often not enough for rotation, especially if the Lambda needs to connect to a database in a VPC. Increase the timeout to 120 seconds minimum. Also check that the Lambda's security group allows outbound traffic to both the Secrets Manager endpoint and the target database.
5. DecryptionFailure with KMS
DecryptionFailure: The ciphertext refers to a customer master key that does not exist,
does not exist in this region, or you are not allowed to access.
This occurs when you move secrets across regions or when KMS key policies change. Ensure the KMS key used to encrypt the secret exists in the same region and that the calling identity has kms:Decrypt permission on that specific key ARN.
Best Practices
Never log secret values. It sounds obvious, but I have seen production systems dump database credentials into CloudWatch Logs via overly verbose error handlers. Log the secret name and version ID, never the value.
Use resource-based policies for cross-account access instead of sharing IAM credentials. Resource policies are auditable, revocable, and follow the principle of least privilege.
Tag every secret with at least
Environment,Application, andOwner. Tags enable cost allocation, IAM policy conditions, and quick identification during incident response.Set up CloudWatch alarms for rotation failures. A failed rotation can leave your application with credentials that will expire. Monitor the
RotationFailedCloudTrail event and alert your on-call team immediately.Use VPC endpoints for Secrets Manager if your application runs in a VPC. This avoids routing API calls through the public internet and eliminates the need for a NAT Gateway on that traffic path. The endpoint type is
com.amazonaws.{region}.secretsmanager.Implement graceful degradation with stale cache. A Secrets Manager API outage should not bring down your application if you have recently cached credentials. Return stale values with a warning log rather than failing the request.
Rotate secrets on a schedule, not just when compromised. Regular rotation limits the blast radius of any credential leak. Even if a credential is exfiltrated, it becomes useless within the rotation window.
Use separate secrets for separate concerns. Do not stuff database credentials, API keys, and OAuth tokens into a single secret. Separate secrets allow granular IAM policies and independent rotation schedules.
Prefer
GenerateSecretStringin CloudFormation over hardcoded passwords. Let Secrets Manager generate strong random passwords that never appear in your template source code or CloudFormation state.Test rotation in staging before enabling it in production. Rotation failures can lock you out of your database. Run at least three successful rotation cycles in a non-production environment before enabling it on production secrets.