IAM Key Rotation
Automated 90-day IAM access key rotation using Secrets Manager, Lambda, and SES notifications — satisfying Prowler without manual ops.
- DATE:
- APR.23.2026
- READ:
- 8 MIN
The compliance requirement
Prowler’s iam-access-key-rotated check flags any IAM access key older than 90 days. For service accounts that need long-lived credentials (Bedrock API access, ECR deployments, CI/CD pipelines), you can’t just delete the key — something is using it. You need to rotate it: create a new key, verify it works, promote it, deactivate the old one, and notify the team.
Doing this manually every 90 days for a dozen service accounts is an operational tax. Secrets Manager rotation schedules automate it.
The rotation Lambda
The rotation follows Secrets Manager’s four-step protocol: createSecret, setSecret, testSecret, finishSecret. Each step is called separately by Secrets Manager — the Lambda must handle all four.
def _create_secret(secret_id, token):
current = sm_client.get_secret_value(
SecretId=secret_id,
VersionStage='AWSCURRENT'
)
current_secret = json.loads(current['SecretString'])
user_name = current_secret['USER_NAME']
# Delete oldest key if 2+ exist (IAM limit)
existing_keys = iam_client.list_access_keys(
UserName=user_name
)['AccessKeyMetadata']
if len(existing_keys) >= 2:
inactive = [k for k in existing_keys
if k['Status'] == 'Inactive']
if inactive:
iam_client.delete_access_key(
UserName=user_name,
AccessKeyId=inactive[0]['AccessKeyId'],
)
# Create new key
new_key = iam_client.create_access_key(
UserName=user_name
)['AccessKey']
new_secret = {
**current_secret,
'AWS_ACCESS_KEY_ID': new_key['AccessKeyId'],
'AWS_SECRET_ACCESS_KEY': new_key['SecretAccessKey'],
}
sm_client.put_secret_value(
SecretId=secret_id,
ClientRequestToken=token,
SecretString=json.dumps(new_secret),
VersionStages=['AWSPENDING'],
) Verification before promotion
The testSecret step verifies the new key actually works before it becomes the active credential:
def _test_secret(secret_id, token):
pending = sm_client.get_secret_value(
SecretId=secret_id,
VersionId=token,
VersionStage='AWSPENDING',
)
secret = json.loads(pending['SecretString'])
# Use the new key to call STS
sts = boto3.client(
'sts',
aws_access_key_id=secret['AWS_ACCESS_KEY_ID'],
aws_secret_access_key=secret['AWS_SECRET_ACCESS_KEY'],
)
identity = sts.get_caller_identity()
if secret['USER_NAME'] not in identity['Arn']:
raise ValueError(
f"New key does not belong to {secret['USER_NAME']}"
) If sts:GetCallerIdentity fails or returns the wrong user, the rotation aborts. The pending version is never promoted, and the current key stays active. No downtime.
Email notification
After successful rotation, the Lambda sends an email via SES so the team knows credentials have changed:
def _send_notification(secret_data, secret_id):
notify_email = secret_data.get('NOTIFY_EMAIL')
if notify_email:
ses_client.send_email(
Source='alerts@acmecorp.com',
Destination={'ToAddresses': [notify_email]},
Message={
'Subject': {
'Data': f'Access key rotated: {secret_id}'
},
'Body': {
'Text': {
'Data': f'New key ID: {secret_data["AWS_ACCESS_KEY_ID"]}'
}
},
},
) CDK wiring
The rotation schedule is attached per secret:
from aws_cdk import aws_secretsmanager as secretsmanager
from aws_cdk import Duration
rotation_lambda = _lambda.Function(
self, "IamKeyRotationLambda",
function_name="acmecorp-iam-key-rotation",
runtime=_lambda.Runtime.PYTHON_3_12,
handler="index.handler",
timeout=Duration.seconds(60),
code=_lambda.Code.from_inline(IAM_KEY_ROTATION_LAMBDA_CODE),
)
# Attach rotation to each service account secret
for secret_name in [
"/acmecorp/bedrock/model/acmecorp-ai/credentials",
"/acmecorp/service/ecr-deployments/credentials",
"/acmecorp/service/bitbucket/credentials",
]:
secret = secretsmanager.Secret.from_secret_name_v2(
self, f"Secret{secret_name}",
secret_name=secret_name,
)
secretsmanager.RotationSchedule(
self, f"Rotation{secret_name}",
secret=secret,
rotation_lambda=rotation_lambda,
automatically_after=Duration.days(90),
) No-op rotation for database secrets
Not all secrets can be rotated automatically. Database passwords require coordinated updates across all consumers. For these, a no-op rotation Lambda satisfies Prowler without changing the actual value:
def handler(event, context):
step = event['Step']
if step == 'createSecret':
# Copy current value to AWSPENDING (no actual change)
current = client.get_secret_value(
SecretId=event['SecretId'],
VersionStage='AWSCURRENT',
)
client.put_secret_value(
SecretId=event['SecretId'],
ClientRequestToken=event['ClientRequestToken'],
SecretString=current['SecretString'],
VersionStages=['AWSPENDING'],
)
# Other steps: pass through
return {"statusCode": 200} This rotates the version label (AWSCURRENT → AWSPREVIOUS) without changing the password. Prowler sees rotation enabled with a 90-day schedule and passes the check.
What gets rotated
+--------------------+----------------+----------+--------------+ | Secret | Rotation type | Schedule | Notification | +--------------------+----------------+----------+--------------+ | Bedrock API users | Real (IAM key) | 90 days | SES email | +--------------------+----------------+----------+--------------+ | ECR deployment | Real (IAM key) | 90 days | SES email | | user | | | | +--------------------+----------------+----------+--------------+ | Bitbucket service | Real (IAM key) | 90 days | SES email | | account | | | | +--------------------+----------------+----------+--------------+ | Database passwords | No-op | 90 days | None | | (dev) | | | | +--------------------+----------------+----------+--------------+ | Database passwords | No-op | 90 days | None | | (prod) | | | | +--------------------+----------------+----------+--------------+
Rotation should be infrastructure, not a calendar reminder. If a human has to remember to rotate a key, the key will eventually be stale.