Intermediate
The typical story goes like this: credentials get created once, embedded in environment variables or config files, and then
they live there forever because “rotating them means downtime.” That story ends here.
AWS Secrets Manager has built-in support for automatic credential rotation using Lambda functions. When configured correctly,
it can rotate your Amazon RDS credentials on a schedule — without your application ever experiencing a failed connection.
This guide walks you through exactly how to set that up, what happens under the hood during each rotation step, and the
gotchas that will bite you if you skip the details.
Who should read this: Backend engineers, DevOps engineers, and cloud architects who are already using
Amazon RDS and AWS Secrets Manager (or planning to), and want to move from static credentials to automatic rotation without
breaking production. You should be comfortable with the AWS Console, AWS CLI, Python basics, and IAM concepts.
Prerequisites
- An AWS account with appropriate IAM permissions (Secrets Manager, Lambda, RDS, VPC, IAM)
- An existing Amazon RDS instance (MySQL, PostgreSQL, or Aurora) running in a VPC
- AWS CLI v2 installed and configured (
aws configure) - Python 3.11+ if you plan to customize the rotation Lambda
- Basic understanding of VPC networking (subnets, security groups)
- The
boto3library familiarity is helpful but not required
How Secrets Manager Rotation Actually Works
Before writing a single line of code, you need to understand the four-step rotation model that Secrets Manager uses.
Every rotation — whether triggered manually or on a schedule — calls your Lambda function four times, once per step.
Getting these steps wrong is the number one cause of rotation failures and application outages.
The Four Rotation Steps
| Step | Lambda Event (Step value) |
What It Should Do |
|---|---|---|
| 1 | createSecret |
Create a new version of the secret with a new random password (status: AWSPENDING) |
| 2 | setSecret |
Apply the new password to the actual RDS database using the current credentials |
| 3 | testSecret |
Verify the new credentials can actually connect to the database |
| 4 | finishSecret |
Promote AWSPENDING to AWSCURRENT and demote old version to AWSPREVIOUS |
The key insight for zero-downtime rotation is this: your application should always fetch the secret by calling
GetSecretValue (or use a caching layer) rather than reading it once at startup. Between steps 2 and 4,
both the old (AWSCURRENT) and new (AWSPENDING) passwords are valid at the database level.
This overlap window is what makes zero downtime possible.
Step 1 — Store Your RDS Credentials in Secrets Manager
If you haven’t already stored your credentials in Secrets Manager, start here. Secrets Manager has a specific JSON
format it expects for RDS secrets. Stick to this format — the managed rotation Lambda templates depend on it.
# Create a secret with the RDS-compatible JSON structure
aws secretsmanager create-secret \
--name "prod/myapp/rds-credentials" \
--description "RDS MySQL credentials for myapp production" \
--secret-string '{
"engine": "mysql",
"host": "myapp-db.cluster-xyz.us-east-1.rds.amazonaws.com",
"username": "myapp_user",
"password": "InitialPassword123!",
"dbname": "myappdb",
"port": 3306
}' \
--region us-east-1
The engine, host, username, password, dbname,
and port keys are all expected by the AWS-managed rotation Lambda templates. Missing any of these
will cause your rotation to fail silently at the setSecret step.
Verify the Secret Was Created
aws secretsmanager describe-secret \
--secret-id "prod/myapp/rds-credentials" \
--region us-east-1
Step 2 — Configure VPC, Security Groups, and IAM for the Rotation Lambda
This is where most tutorials skip the important parts. Your rotation Lambda needs to reach both the
Secrets Manager API endpoint and your RDS instance. If your RDS is in a private subnet (as it should be),
you have two options:
- Option A (Recommended): Deploy the Lambda inside the same VPC as RDS and add a Secrets Manager
VPC endpoint so Lambda doesn’t need internet access. - Option B: Deploy Lambda inside the VPC and add a NAT Gateway to allow outbound internet access
to the Secrets Manager public endpoint. This works but adds cost and complexity.
Create a VPC Endpoint for Secrets Manager
# Find your VPC ID
aws ec2 describe-vpcs \
--filters "Name=tag:Name,Values=myapp-vpc" \
--query "Vpcs[0].VpcId" \
--output text \
--region us-east-1
# Create an interface endpoint for Secrets Manager
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0abc12345def67890 \
--vpc-endpoint-type Interface \
--service-name com.amazonaws.us-east-1.secretsmanager \
--subnet-ids subnet-0private1 subnet-0private2 \
--security-group-ids sg-0yoursecuritygroup \
--private-dns-enabled \
--region us-east-1
Security Group Rules
Your rotation Lambda’s security group needs:
- Outbound port 3306 (MySQL) or 5432 (PostgreSQL) to the RDS security group
- Outbound port 443 to the Secrets Manager VPC endpoint security group (or
0.0.0.0/0
if using NAT)
Your RDS security group needs:
- Inbound port 3306/5432 from the Lambda security group
IAM Role for the Rotation Lambda
# Create the IAM role trust policy
cat > lambda-trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
EOF
# Create the role
aws iam create-role \
--role-name SecretsManagerRotationLambdaRole \
--assume-role-policy-document file://lambda-trust-policy.json
# Attach the AWS managed policy for basic Lambda execution
aws iam attach-role-policy \
--role-name SecretsManagerRotationLambdaRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
# Create and attach custom policy for Secrets Manager access
cat > secrets-rotation-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecretVersionStage"
],
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/myapp/rds-credentials-*"
},
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetRandomPassword"
],
"Resource": "*"
}
]
}
EOF
aws iam put-role-policy \
--role-name SecretsManagerRotationLambdaRole \
--policy-name SecretsManagerRotationPolicy \
--policy-document file://secrets-rotation-policy.json
Step 3 — Deploy the Rotation Lambda Function
AWS provides managed rotation Lambda templates via the Serverless Application Repository. For RDS, the easiest
path is to use the SecretsManagerRDSMySQLRotationSingleUser or
SecretsManagerRDSPostgreSQLRotationSingleUser templates. However, understanding what the Lambda
does — and being able to customize it — is essential for production use.
Below is a complete, production-ready rotation Lambda for MySQL using the single-user strategy (rotating the
password for the same database user). This is the most common pattern.
import boto3
import json
import logging
import os
import pymysql
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
“””
Secrets Manager RDS MySQL Rotation Handler (Single User Strategy)
Handles all four rotation steps: createSecret, setSecret, testSecret, finishSecret
“””
arn = event[‘SecretId’]
token = event[‘ClientRequestToken’]
step = event[‘Step’]
service_client = boto3.client(‘secretsmanager’, endpoint_url=os.environ.get(‘SECRETS_MANAGER_ENDPOINT’))
# Verify the secret version is staged correctly for rotation
metadata = service_client.describe_secret(SecretId=arn)
if not metadata[‘RotationEnabled’]:
logger.error(f”Secret {arn} is not enabled for rotation”)
raise ValueError(f”Secret {arn} is not enabled for rotation”)
versions = metadata.get(‘VersionIdsToStages’, {})
if token not in versions:
logger.error(f”Secret version {token} has no stage for rotation of secret {arn}”)
raise ValueError(f”Secret version {token} has no stage for rotation of secret {arn}”)
if ‘AWSCURRENT’ in versions[token]:
logger.info(f”Secret version {token} is already set as AWSCURRENT for secret {arn}. Nothing to do.”)
return
elif ‘AWSPENDING’ not in versions[token]:
logger.error(f”Secret version {token} is not set as AWSPENDING for secret {arn}”)
raise ValueError(f”Secret version {token} is not set as AWSPENDING for secret {arn}”)
if step == ‘createSecret’:
create_secret(service_client, arn, token)
elif step == ‘setSecret’:
set_secret(service_client, arn, token)
elif step == ‘testSecret’:
test_secret(service_client, arn, token)
elif step == ‘finishSecret’:
finish_secret(service_client, arn, token)
else:
raise ValueError(f”Invalid step parameter: {step}”)
def create_secret(service_client, arn, token):
“””Step 1: Create a new secret version with a new random password.”””
# Check if AWSPENDING already exists (handles retry scenarios)
try:
service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=’AWSPENDING’)
logger.info(f”createSecret: AWSPENDING already exists for {arn}. Skipping creation.”)
return
except service_client.exceptions.ResourceNotFoundException:
pass
# Get the current secret to use as a template
current_secret = json.loads(
service_client.get_secret_value(SecretId=arn, VersionStage=’AWSCURRENT’)[‘SecretString’]
)
# Generate a new random password
new_password = service_client.get_random_password(
PasswordLength=32,
ExcludeCharacters=’/@”\’\\’ # Exclude chars that cause issues in MySQL connection strings
)[‘RandomPassword’]
current_secret[‘password’] = new_password
# Store the new version as AWSPENDING
service_client.put_secret_value(
SecretId=arn,
ClientRequestToken=token,
SecretString=json.dumps(current_secret),
VersionStages=[‘AWSPENDING’]
)
logger.info(f”createSecret: Successfully created AWSPENDING secret version for {arn}”)
def set_secret(service_client, arn, token):
“””Step 2: Apply the new password to the RDS database.”””
pending_secret = json.loads(
service_client.get_secret_value(SecretId=arn, VersionId=token, VersionStage=’AWSPENDING’)[‘SecretString’]
)
current_secret = json.loads(
service_client.get_secret_value(SecretId=arn, VersionStage=’AWSCURRENT’)[‘SecretString’]
)
# Connect using CURRENT credentials and update to PENDING password
connection = get_connection(current_secret)
try:
with connection.cursor() as cursor:
# MySQL 8.0+ syntax
cursor.execute(
“ALTER USER %s@’%%’ IDENTIFIED BY %s”,
(pending_secret[‘username’], pending_secret[‘password’])
)
connection.commit()
logger.info(f”setSecret: Successfully set new password for user {pending_secret[‘username’]}”)
finally:
connection.close()
def test_secret(service_client, arn, token):
“””Step 3: Verify the new credentials actually work.”””
pending_secret = json.loads(
service_client.get_secret_value(SecretId=arn, VersionId=token, Version