How to Rotate AWS IAM Access Keys Automatically with Lambda
Key takeaways
- IAM access keys older than 90 days create security vulnerabilities—AWS recommends rotation but doesn't enforce it automatically
- Automated rotation using Lambda + EventBridge scans all IAM users daily, identifies keys over threshold (60/90 days), and sends email notifications via SES
- Grace period workflow (warning at 60 days, mandatory rotation at 90 days) gives users time to update applications before forced rotation
- Secrets Manager integration stores rotated keys and enables automatic application retrieval without manual updates
- Complete audit trail via CloudWatch Logs and DynamoDB tracking ensures compliance with SOC 2, ISO 27001, and PCI-DSS requirements
The IAM Access Key Problem
Your security audit just flagged 47 IAM users with access keys older than 90 days. Three are over 2 years old. One belongs to an engineer who left 6 months ago.
The risk:
- Compromised keys = full AWS account access
- No rotation = extended exposure window
- Shared keys = impossible to audit who did what
- Forgotten keys = zombie credentials
Industry benchmarks:
- AWS recommendation: Rotate every 90 days
- SOC 2 requirement: Access key rotation policy
- PCI-DSS: Change authentication credentials every 90 days
- ISO 27001: Periodic credential review
Manual rotation doesn't scale:
- Send email to 47 users
- Track who rotated, who didn't
- Follow up with non-compliant users
- Disable old keys manually
- Repeat every 90 days
Solution: Automated key rotation with Lambda
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ EventBridge Schedule │
│ (Daily at 9 AM UTC) │
└────────────┬────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Lambda Function │
│ Key Rotation │
│ Orchestrator │
└────────┬────────┘
│
├─────────────────────┬──────────────────────┬───────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ IAM API │ │ SES │ │ Secrets │ │ DynamoDB │
│ List Users │ │ Send Email │ │ Manager │ │ Audit Log │
│ List Keys │ │ Notifications│ │ Store Keys │ │ Tracking │
└─────────────┘ └──────────────┘ └─────────────┘ └──────────────┘
│ │ │ │
└─────────────────────┴──────────────────────┴───────────────────┘
│
▼
CloudWatch Logs
(Audit Trail)
Implementation: Step-by-Step
Step 1: Lambda Function - Key Age Detection
import { IAM, SES } from 'aws-sdk';
import { Handler } from 'aws-lambda';
const iam = new IAM();
const ses = new SES();
interface KeyAgeReport {
userName: string;
accessKeyId: string;
ageInDays: number;
status: 'Active' | 'Inactive';
lastUsed?: Date;
}
export const handler: Handler = async (event) => {
console.log('Starting IAM key rotation check');
const users = await iam.listUsers().promise();
const agingKeys: KeyAgeReport[] = [];
for (const user of users.Users || []) {
const accessKeys = await iam.listAccessKeys({
UserName: user.UserName
}).promise();
for (const key of accessKeys.AccessKeyMetadata || []) {
if (!key.CreateDate || key.Status !== 'Active') continue;
const ageInDays = calculateAgeInDays(key.CreateDate);
// Check last usage
const lastUsed = await iam.getAccessKeyLastUsed({
AccessKeyId: key.AccessKeyId!
}).promise();
const report: KeyAgeReport = {
userName: user.UserName!,
accessKeyId: key.AccessKeyId!,
ageInDays,
status: key.Status as 'Active',
lastUsed: lastUsed.AccessKeyLastUsed?.LastUsedDate
};
// Threshold: 60 days = warning, 90 days = rotate
if (ageInDays >= 60) {
agingKeys.push(report);
}
}
}
// Process aging keys
await processAgingKeys(agingKeys);
return {
statusCode: 200,
body: JSON.stringify({
totalUsersScanned: users.Users?.length || 0,
agingKeysFound: agingKeys.length,
timestamp: new Date().toISOString()
})
};
};
function calculateAgeInDays(createDate: Date): number {
const now = new Date();
const diffMs = now.getTime() - createDate.getTime();
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}
async function processAgingKeys(keys: KeyAgeReport[]): Promise<void> {
for (const key of keys) {
if (key.ageInDays >= 90) {
// Force rotation after 90 days
await rotateKey(key);
} else if (key.ageInDays >= 60) {
// Send warning email
await sendWarningEmail(key);
}
}
}Step 2: Email Notification System
async function sendWarningEmail(key: KeyAgeReport): Promise<void> {
const user = await iam.getUser({ UserName: key.userName }).promise();
const email = user.User?.Tags?.find(t => t.Key === 'Email')?.Value;
if (!email) {
console.log(`No email tag found for user: ${key.userName}`);
return;
}
const daysUntilRotation = 90 - key.ageInDays;
const emailParams = {
Source: process.env.SENDER_EMAIL!,
Destination: {
ToAddresses: [email],
CcAddresses: [process.env.SECURITY_TEAM_EMAIL!]
},
Message: {
Subject: {
Data: `⚠️ AWS Access Key Rotation Required (${daysUntilRotation} days remaining)`
},
Body: {
Html: {
Data: `
<html>
<body>
<h2>AWS Access Key Rotation Notice</h2>
<p>Your AWS access key requires rotation:</p>
<table style="border-collapse: collapse; margin: 20px 0;">
<tr>
<td style="padding: 8px; border: 1px solid #ddd;"><strong>User:</strong></td>
<td style="padding: 8px; border: 1px solid #ddd;">${key.userName}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;"><strong>Access Key ID:</strong></td>
<td style="padding: 8px; border: 1px solid #ddd;">${key.accessKeyId}</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;"><strong>Age:</strong></td>
<td style="padding: 8px; border: 1px solid #ddd;">${key.ageInDays} days</td>
</tr>
<tr>
<td style="padding: 8px; border: 1px solid #ddd;"><strong>Last Used:</strong></td>
<td style="padding: 8px; border: 1px solid #ddd;">${key.lastUsed?.toISOString() || 'Never'}</td>
</tr>
<tr style="background-color: #fff3cd;">
<td style="padding: 8px; border: 1px solid #ddd;"><strong>Days Until Forced Rotation:</strong></td>
<td style="padding: 8px; border: 1px solid #ddd;"><strong>${daysUntilRotation} days</strong></td>
</tr>
</table>
<h3>Action Required:</h3>
<ol>
<li>Create a new access key in the AWS Console</li>
<li>Update your applications to use the new key</li>
<li>Test thoroughly</li>
<li>Delete the old key: <code>${key.accessKeyId}</code></li>
</ol>
<h3>Consequences of Inaction:</h3>
<ul>
<li>In ${daysUntilRotation} days, this key will be <strong>automatically rotated</strong></li>
<li>Applications using the old key will fail authentication</li>
<li>You will receive the new key via Secrets Manager</li>
</ul>
<p><a href="https://console.aws.amazon.com/iam/home#/users/${key.userName}?section=security_credentials">View in AWS Console →</a></p>
<hr />
<p style="color: #666; font-size: 12px;">
This is an automated message from AWS Security Automation.<br />
Questions? Contact security@company.com
</p>
</body>
</html>
`
}
}
}
};
await ses.sendEmail(emailParams).promise();
console.log(`Warning email sent to ${email} for user ${key.userName}`);
}Step 3: Automated Key Rotation
import { SecretsManager } from 'aws-sdk';
const secretsManager = new SecretsManager();
async function rotateKey(key: KeyAgeReport): Promise<void> {
console.log(`Force rotating key for user: ${key.userName}`);
try {
// 1. Create new access key
const newKey = await iam.createAccessKey({
UserName: key.userName
}).promise();
if (!newKey.AccessKey) {
throw new Error('Failed to create new access key');
}
// 2. Store new key in Secrets Manager
const secretName = `iam/${key.userName}/access-key`;
try {
await secretsManager.createSecret({
Name: secretName,
SecretString: JSON.stringify({
AccessKeyId: newKey.AccessKey.AccessKeyId,
SecretAccessKey: newKey.AccessKey.SecretAccessKey,
CreatedDate: newKey.AccessKey.CreateDate?.toISOString(),
RotatedFrom: key.accessKeyId
}),
Tags: [
{ Key: 'User', Value: key.userName },
{ Key: 'RotationDate', Value: new Date().toISOString() }
]
}).promise();
} catch (error: any) {
if (error.code === 'ResourceExistsException') {
// Update existing secret
await secretsManager.putSecretValue({
SecretId: secretName,
SecretString: JSON.stringify({
AccessKeyId: newKey.AccessKey.AccessKeyId,
SecretAccessKey: newKey.AccessKey.SecretAccessKey,
CreatedDate: newKey.AccessKey.CreateDate?.toISOString(),
RotatedFrom: key.accessKeyId
})
}).promise();
} else {
throw error;
}
}
// 3. Deactivate old key (don't delete immediately)
await iam.updateAccessKey({
UserName: key.userName,
AccessKeyId: key.accessKeyId,
Status: 'Inactive'
}).promise();
// 4. Send notification with new key location
await sendRotationNotification(key, newKey.AccessKey.AccessKeyId!, secretName);
// 5. Log to DynamoDB for audit trail
await logRotation(key, newKey.AccessKey.AccessKeyId!);
console.log(`Successfully rotated key ${key.accessKeyId} → ${newKey.AccessKey.AccessKeyId}`);
} catch (error) {
console.error(`Failed to rotate key for ${key.userName}:`, error);
// Send alert to security team
await sendErrorAlert(key, error);
throw error;
}
}
async function sendRotationNotification(
oldKey: KeyAgeReport,
newKeyId: string,
secretName: string
): Promise<void> {
const user = await iam.getUser({ UserName: oldKey.userName }).promise();
const email = user.User?.Tags?.find(t => t.Key === 'Email')?.Value;
if (!email) return;
const emailParams = {
Source: process.env.SENDER_EMAIL!,
Destination: {
ToAddresses: [email],
CcAddresses: [process.env.SECURITY_TEAM_EMAIL!]
},
Message: {
Subject: {
Data: `🔄 AWS Access Key Has Been Rotated`
},
Body: {
Html: {
Data: `
<html>
<body>
<h2>Access Key Rotation Complete</h2>
<p>Your AWS access key has been automatically rotated due to age (${oldKey.ageInDays} days).</p>
<h3>Old Key (DEACTIVATED):</h3>
<pre style="background: #f5f5f5; padding: 10px;">${oldKey.accessKeyId}</pre>
<h3>New Key:</h3>
<pre style="background: #d4edda; padding: 10px;">${newKeyId}</pre>
<h3>Retrieve New Credentials:</h3>
<p>The new access key and secret are stored in AWS Secrets Manager:</p>
<pre style="background: #f5f5f5; padding: 10px;">
aws secretsmanager get-secret-value \\
--secret-id ${secretName} \\
--query SecretString \\
--output text | jq -r</pre>
<h3>Update Your Applications:</h3>
<ol>
<li>Retrieve the new credentials from Secrets Manager</li>
<li>Update your application configuration</li>
<li>Test thoroughly</li>
<li>The old key will be deleted in 7 days</li>
</ol>
<h3>Automated Retrieval (Recommended):</h3>
<p>Configure your applications to fetch credentials from Secrets Manager on startup:</p>
<pre style="background: #f5f5f5; padding: 10px;">
import { SecretsManager } from 'aws-sdk';
const secretsManager = new SecretsManager();
const secret = await secretsManager.getSecretValue({
SecretId: '${secretName}'
}).promise();
const credentials = JSON.parse(secret.SecretString!);
// Use credentials.AccessKeyId and credentials.SecretAccessKey
</pre>
<p><strong>Questions?</strong> Contact security@company.com</p>
</body>
</html>
`
}
}
}
};
await ses.sendEmail(emailParams).promise();
}Step 4: Audit Trail with DynamoDB
import { DynamoDB } from 'aws-sdk';
const dynamodb = new DynamoDB.DocumentClient();
async function logRotation(oldKey: KeyAgeReport, newKeyId: string): Promise<void> {
await dynamodb.put({
TableName: process.env.AUDIT_TABLE_NAME!,
Item: {
pk: `USER#${oldKey.userName}`,
sk: `ROTATION#${new Date().toISOString()}`,
oldKeyId: oldKey.accessKeyId,
newKeyId: newKeyId,
keyAge: oldKey.ageInDays,
rotationType: 'automatic',
rotationReason: 'exceeded_90_day_threshold',
timestamp: new Date().toISOString(),
lastUsed: oldKey.lastUsed?.toISOString() || null
}
}).promise();
}DynamoDB table schema:
resource "aws_dynamodb_table" "key_rotation_audit" {
name = "iam-key-rotation-audit"
billing_mode = "PAY_PER_REQUEST"
hash_key = "pk"
range_key = "sk"
attribute {
name = "pk"
type = "S"
}
attribute {
name = "sk"
type = "S"
}
# Enable Point-in-Time Recovery for compliance
point_in_time_recovery {
enabled = true
}
# Enable server-side encryption
server_side_encryption {
enabled = true
}
tags = {
Purpose = "IAM Key Rotation Audit Trail"
}
}Step 5: Terraform Infrastructure
# Lambda function
resource "aws_lambda_function" "key_rotation" {
filename = "key-rotation.zip"
function_name = "iam-key-rotation"
role = aws_iam_role.lambda.arn
handler = "index.handler"
runtime = "nodejs18.x"
timeout = 300
environment {
variables = {
SENDER_EMAIL = var.sender_email
SECURITY_TEAM_EMAIL = var.security_team_email
AUDIT_TABLE_NAME = aws_dynamodb_table.key_rotation_audit.name
}
}
}
# IAM role for Lambda
resource "aws_iam_role" "lambda" {
name = "iam-key-rotation-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
}
resource "aws_iam_role_policy" "lambda" {
role = aws_iam_role.lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"iam:ListUsers",
"iam:ListAccessKeys",
"iam:GetUser",
"iam:GetAccessKeyLastUsed",
"iam:CreateAccessKey",
"iam:UpdateAccessKey",
"iam:DeleteAccessKey"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"secretsmanager:CreateSecret",
"secretsmanager:PutSecretValue",
"secretsmanager:GetSecretValue"
]
Resource = "arn:aws:secretsmanager:${var.aws_region}:${data.aws_caller_identity.current.account_id}:secret:iam/*"
},
{
Effect = "Allow"
Action = [
"ses:SendEmail",
"ses:SendRawEmail"
]
Resource = "*"
},
{
Effect = "Allow"
Action = [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:Query"
]
Resource = aws_dynamodb_table.key_rotation_audit.arn
},
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "*"
}
]
})
}
# EventBridge schedule - daily at 9 AM UTC
resource "aws_cloudwatch_event_rule" "daily" {
name = "iam-key-rotation-daily"
schedule_expression = "cron(0 9 * * ? *)"
}
resource "aws_cloudwatch_event_target" "lambda" {
rule = aws_cloudwatch_event_rule.daily.name
target_id = "lambda"
arn = aws_lambda_function.key_rotation.arn
}
resource "aws_lambda_permission" "eventbridge" {
statement_id = "AllowExecutionFromEventBridge"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.key_rotation.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.daily.arn
}
# SES email identity
resource "aws_ses_email_identity" "sender" {
email = var.sender_email
}Advanced Feature: Graceful Old Key Deletion
Don't delete old keys immediately—give users 7 days to update applications:
async function scheduleKeyDeletion(keyId: string, userName: string): Promise<void> {
// Store deletion timestamp in DynamoDB
await dynamodb.put({
TableName: process.env.AUDIT_TABLE_NAME!,
Item: {
pk: `DELETION#${keyId}`,
sk: `SCHEDULED`,
userName: userName,
keyId: keyId,
scheduledFor: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
status: 'PENDING'
}
}).promise();
}
// Separate Lambda function triggered daily to delete expired keys
export const deleteExpiredKeysHandler: Handler = async () => {
const now = new Date().toISOString();
const result = await dynamodb.scan({
TableName: process.env.AUDIT_TABLE_NAME!,
FilterExpression: 'begins_with(pk, :prefix) AND scheduledFor < :now AND #status = :pending',
ExpressionAttributeNames: {
'#status': 'status'
},
ExpressionAttributeValues: {
':prefix': 'DELETION#',
':now': now,
':pending': 'PENDING'
}
}).promise();
for (const item of result.Items || []) {
try {
await iam.deleteAccessKey({
UserName: item.userName,
AccessKeyId: item.keyId
}).promise();
// Update status
await dynamodb.update({
TableName: process.env.AUDIT_TABLE_NAME!,
Key: { pk: item.pk, sk: item.sk },
UpdateExpression: 'SET #status = :completed, deletedAt = :now',
ExpressionAttributeNames: { '#status': 'status' },
ExpressionAttributeValues: { ':completed': 'COMPLETED', ':now': now }
}).promise();
console.log(`Deleted old key: ${item.keyId} for user: ${item.userName}`);
} catch (error) {
console.error(`Failed to delete key ${item.keyId}:`, error);
}
}
};Monitoring and Alerting
CloudWatch Dashboard:
resource "aws_cloudwatch_dashboard" "key_rotation" {
dashboard_name = "iam-key-rotation"
dashboard_body = jsonencode({
widgets = [
{
type = "metric"
properties = {
metrics = [
["AWS/Lambda", "Invocations", { stat = "Sum", label = "Total Runs" }],
[".", "Errors", { stat = "Sum", label = "Errors" }],
[".", "Duration", { stat = "Average", label = "Avg Duration" }]
]
period = 86400
stat = "Sum"
region = var.aws_region
title = "Lambda Execution Metrics"
}
},
{
type = "log"
properties = {
query = <<-EOQ
SOURCE '/aws/lambda/iam-key-rotation'
| fields @timestamp, @message
| filter @message like /rotated key/
| sort @timestamp desc
| limit 20
EOQ
region = var.aws_region
title = "Recent Key Rotations"
}
}
]
})
}
# Alert on rotation failures
resource "aws_cloudwatch_metric_alarm" "rotation_failures" {
alarm_name = "iam-key-rotation-failures"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "Errors"
namespace = "AWS/Lambda"
period = 86400
statistic = "Sum"
threshold = 0
alarm_description = "Alert when key rotation fails"
alarm_actions = [aws_sns_topic.security_alerts.arn]
dimensions = {
FunctionName = aws_lambda_function.key_rotation.function_name
}
}Compliance Reporting
Generate quarterly reports for auditors:
export const generateComplianceReportHandler: Handler = async () => {
// Query all rotations in the last 90 days
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
const rotations = await dynamodb.query({
TableName: process.env.AUDIT_TABLE_NAME!,
IndexName: 'timestamp-index',
KeyConditionExpression: '#timestamp > :start',
ExpressionAttributeNames: {
'#timestamp': 'timestamp'
},
ExpressionAttributeValues: {
':start': ninetyDaysAgo
}
}).promise();
// Get all current users and check key ages
const users = await iam.listUsers().promise();
const complianceStatus = [];
for (const user of users.Users || []) {
const accessKeys = await iam.listAccessKeys({
UserName: user.UserName
}).promise();
for (const key of accessKeys.AccessKeyMetadata || []) {
if (key.Status !== 'Active') continue;
const ageInDays = calculateAgeInDays(key.CreateDate!);
complianceStatus.push({
userName: user.UserName,
keyId: key.accessKeyId,
ageInDays: ageInDays,
compliant: ageInDays < 90
});
}
}
const totalKeys = complianceStatus.length;
const compliantKeys = complianceStatus.filter(k => k.compliant).length;
const complianceRate = (compliantKeys / totalKeys) * 100;
const report = {
reportDate: new Date().toISOString(),
period: '90 days',
totalKeys: totalKeys,
compliantKeys: compliantKeys,
nonCompliantKeys: totalKeys - compliantKeys,
complianceRate: complianceRate.toFixed(2) + '%',
totalRotations: rotations.Items?.length || 0,
details: complianceStatus
};
// Store report in S3
const s3 = new (require('aws-sdk').S3)();
await s3.putObject({
Bucket: process.env.REPORTS_BUCKET!,
Key: `compliance-reports/iam-keys-${new Date().toISOString().split('T')[0]}.json`,
Body: JSON.stringify(report, null, 2),
ContentType: 'application/json'
}).promise();
// Send summary to security team
await sendComplianceReportEmail(report);
return report;
};Best Practices
1. Tag Users with Email Addresses
# Add email tags to IAM users for notifications
aws iam tag-user \
--user-name john.doe \
--tags Key=Email,Value=john.doe@company.com2. Exemptions for Service Accounts
const EXEMPTED_USERS = [
'terraform-ci',
'github-actions',
'datadog-agent'
];
// In your Lambda function
if (EXEMPTED_USERS.includes(key.userName)) {
console.log(`Skipping exempted user: ${key.userName}`);
continue;
}3. Testing Before Production
# Test Lambda function locally
sam local invoke KeyRotationFunction \
--event test-event.json
# Test with one user first
export TEST_MODE=true
export TEST_USER=john.doe4. Gradual Rollout
Week 1: Warning emails only (no rotation)
Week 2: Rotation for 5 test users
Week 3: Rotation for 25% of users
Week 4: Full rollout
Conclusion: Automation is Security
Manual key rotation doesn't scale and creates compliance gaps. Automated rotation:
- Reduces breach risk: 90-day keys limit exposure window
- Ensures compliance: SOC 2, PCI-DSS, ISO 27001 requirements met automatically
- Saves time: 15 minutes/month manual work → 0 minutes
- Provides audit trails: Complete DynamoDB history for auditors
- Forces best practices: Users must retrieve keys from Secrets Manager
Action Items
- Audit current key ages: Run AWS CLI command to find keys over 90 days
- Deploy Lambda function: Use Terraform configuration provided
- Tag IAM users: Add email tags for notifications
- Test with yourself: Trigger rotation for your own test user
- Enable EventBridge schedule: Start daily scans
- Monitor for 30 days: Ensure no false positives or rotation failures
If you need help implementing automated IAM key rotation, schedule a consultation. We'll audit your current IAM setup, deploy the rotation system with custom exemptions and notification templates, and provide runbooks for your security team.