Scenario
In my project, we have over 10+ users with access keys. Most of the access keys were 300–400 days old.
Having access keys lying around for a long period poses a security risk, which is unacceptable. They’re not just a minor security issue — they’re multimillion-dollar liabilities.
The issue I can see is that no one remembers to rotate the access keys, so I thought of building a Lambda function that could remind the team to rotate the keys after a certain number of days.
Different organizations enforce varying rotation policies — some require rotation every 30 days, others allow 60 or 90 days before access keys must be refreshed.
My solution needed to accommodate these different requirements while being easy to deploy and maintain.
What to expect in this blog post
In this blog post, I’ll walk you through the complete implementation of an automated access key rotation reminder system. You’ll learn how to build a Lambda function that monitors key ages, sends targeted notifications, and helps your team maintain robust security hygiene without the overhead of manual tracking.
✅ You can find the entire code for this blog post on my public GitHub repo.
Writing the Python code
I used AWS Python SDK, boto3, for the automation. Here is the approach I took.
- List the users and access keys
- Calculate the age
- Compare it with age standards, and determine if this key should be rotated.
- If the key should be rotated, build the email body that includes the link to the user
- Send the email using AWS SES(Simple Email Service)
- Building the code
- Before we deploy a fully functioning Lambda, I will build and test the code locally.
Note: Also, configure the AWS credentials -> aws configure locally
I will use boto3, datetime, and email Python modules. datetime and email are Python built-in modules, but we need to install boto3, which is the AWS-managed library.
- Install the boto3 I will use a Python virtual environment and install boto3 with that
python3 -m venv .venv
# activate the virtual environemnts on my mac. There is a different commnad
# windows.
source .venv/bin/activate
Install boto3 with pip
pip install boto3
Now that we have the installation done, let's write the code step by step.
1. List the users
import boto3
def get_users():
iam_client = boto3.client('iam')
response = iam_client.list_users()
return [user['UserName'] for user in response['Users']]
print(get_users())
2. Get the access keys details
I will use the datetime module to calculate the age from the created date parameter of the access key
def get_access_keys_age(username):
iam_client = boto3.client('iam')
response = iam_client.list_access_keys(UserName=username).get('AccessKeyMetadata', [])
access_keys_info = []
for item in response:
if item['Status'] == 'Active':
access_key_id = item['AccessKeyId']
create_date = item['CreateDate'].date()
age = (date.today() - create_date).days
access_keys_info.append((access_key_id, age))
return access_keys_info
print(get_access_keys_age('cliuser-akhilesh'))
As you can see, the user cliuser-akhilesh has 2 access keys, one with 22 days' age and the other with 2 days.
For this use case, I will set the access key expiry age as 20 days, and I would want to send email from 15 days (I would want to have 5 days as a buffer to ensure people responsible for rotating email get enough time to address this)
3.Check if the expired key
I want a function that returns an HTML message if the keys are expired. I would include a dynamic link of the AWS IAM user for which the keys have expired.
username = "cliuser-akhilesh"
Expiry_days = 20
reminder_email_age = Expiry_days - 5
def if_key_expired(access_key_id, age, reminder_email_age):
if age >= reminder_email_age:
return f'''
<p>Reminder: Access key <strong>{access_key_id}</strong> is <strong>{age}</strong> days old. Please rotate it.</p>
<p>For more details, visit the <a href="https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users/details/{username}?section=security_credentials"> Rotate this key here</a>.</p>
'''
return None
print(if_key_expired("AKIA4ZPZU3T7QIPRKR5X", 22, reminder_email_age))
That HTML will look like this
4.Process users
This will return the email body, only for users with access keys about to expire. We will only build an email for these users/access_keys and send an email
def process_users():
email_body_list = []
users = get_users()
for user in users:
access_keys_info = get_access_keys_age(user)
for keys in access_keys_info:
access_key_id, age = keys
email_body = if_key_expired(access_key_id, age, reminder_email_age)
if email_body:
email_body_list.append(email_body)
return email_body_list
print(type(process_users()))
print(process_users())
5. Build an email with the Python email library
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
def build_email_message(to_email, from_email, subject, body):
msg = MIMEMultipart()
msg['From'] = from_email
msg['To'] = to_email
msg['Subject'] = subject
body_part = MIMEText(body, 'html')
msg.attach(body_part)
return msg
6. Send an email using the AWS SES service
# Create ses clinet
ses_client = boto3.client('ses')
def send_email(msg, to_emails):
response = ses_client.send_raw_email(
Source=msg["From"],
Destinations=to_emails,
RawMessage={"Data": msg.as_string()},
)
return response.get('MessageId', None)
7. Now let’s put it all together
This function will find all the users with expiring access keys and send an email. I used my emails for this demo; in real scenarios, you can send an email to the whole team.
def main():
subject = f"AWS Access Key Rotation Reminder -user {username}"
to_email = "[email protected]"
from_email = "[email protected]"
for email_body in process_users():
email_msg = build_email_message(to_email, from_email, subject, email_body)
email_sent =send_email(email_msg, [to_email])
print(f"Email sent with Message ID: {email_sent}")
I will go to Lambda and test it. I should get an email.
And here it is
Now that we have tested the code on the local machine, we are ready to deploy it on Lambda.
Only one part will change on Lambda, the main function will look something like this.
def main(event, context):
subject = f"AWS Access Key Rotation Reminder -user {username}"
to_email = "[email protected]"
from_email = "[email protected]"
for email_body in process_users():
email_msg = build_email_message(to_email, from_email, subject, email_body)
email_sent =send_email(email_msg, [to_email])
print(f"Email sent with Message ID: {email_sent}")
The main function will be the entry function for lambda.
AWS SES
You need to validate the identities in AWS before you can send an email to them. Since you will be using the SES sandbox account, you need to validate to_email and from_email.
Go to Amazon SES > Configuration: Identities
Create and validate identities
Terraform implementation
I will follow the steps below to write a Terraform function to deploy the Lambda function.
Lambda will expect zipped code, so I will be archiving the code in a zip format using the Terraform archive_file data source
Lambda will need access to AWS IAM to get the access key details, so I will be creating an IAM role with an IAM policy with access to list users, get access key data, and send email
Create the Lambda function
Create a cron job that will run this Lambda daily
- Archiving the code
# zip the code
data "archive_file" "lambda_zip" {
type = "zip"
source_dir = "${path.module}/lambda/iam-key-rotation"
output_path = "${path.module}/iam-key-rotation.zip"
}
path.module
reference to the path of the Terraform config. We use it to write the relative path for the code files.
- IAM role for the Lambda
# iam role
resource "aws_iam_role" "lambda_role" {
name = "iam-key-rotation-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}
]
})
}
- IAM policy for the Lambda
resource "aws_iam_policy" "lambda_policy" {
name = "iam-key-rotation-policy"
description = "Policy for Lambda function to rotate IAM keys"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"iam:ListAccessKeys",
"iam:ListUsers",
]
Effect = "Allow"
Resource = "*"
},
{
Action = [
"ses:SendEmail",
"ses:SendRawEmail",
]
Effect = "Allow"
Resource = "*"
}
]
})
}
- Attach the policy to the role
#### # attach policy to role
resource "aws_iam_role_policy_attachment" "lambda_policy_attachment" {
role = aws_iam_role.lambda_role.name
policy_arn = aws_iam_policy.lambda_policy.arn
}
- Lambda function
# lambda
resource "aws_lambda_function" "my_lambda_function" {
function_name = "iam-key-rotation"
role = aws_iam_role.lambda_role.arn
handler = "main.main"
runtime = "python3.13"
timeout = 60
memory_size = 128
# Use the Archive data source to zip the code
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
}
source_code_hash will enable Lambda to update the Lambda code whenever a change happens in the Python code.
timeout is set to 60 seconds, if not set, will fall back to the default 3 seconds
handler Use the format python_code.python_function. In this use case, main.py is the code that runs when Lambda is invoked, and main() is the entry-level function. Hence, handler = main.main
To enable the cron job trigger, we use an AWS eventbridge rule.
resource "aws_cloudwatch_event_rule" "cron_lambdas" {
name = "cronjob"
description = "to triggr lambda daily 7.15 pm ist"
schedule_expression = "cron(40 13 * * ? *)"
}
resource "aws_cloudwatch_event_target" "cron_lambdas" {
rule = aws_cloudwatch_event_rule.cron_lambdas.name
arn = aws_lambda_function.my_lambda_function.arn
}
Also, this cron will need permissions to invoke the lambda on schedule
# Invoke lambda permission
resource "aws_lambda_permission" "cron_lambdas" {
statement_id = "key-rotation-lambda-allow"
action = "lambda:InvokeFunction"
principal = "events.amazonaws.com"
function_name = aws_lambda_function.my_lambda_function.arn
source_arn = aws_cloudwatch_event_rule.cron_lambdas.arn
}
Lambda code is set. I will use the Terraform provider AWS and a remote state file
*providers.tf
*
terraform {
required_version = "1.8.1"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.32.0"
}
}
}
provider "aws" {
region = "ap-south-1"
}
# remote backend
terraform {
backend "s3" {
bucket = "state-bucket-879381234673"
key = "lambda-blog/terraform.tfstate"
region = "ap-south-1"
encrypt = true
}
}
Now we can deploy the lambda function
terraform init
terraform plan
terraform apply
Better version of Python and Lambda code
To ensure the automation is flexible and maintainable, I’ll implement a configuration-driven approach using environment variables.
This eliminates hardcoded values and allows dynamic configuration management through Terraform, making the solution easily adaptable across different environments and organizations.
Here is the final version of the code.
main.py
import boto3
from datetime import date
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import os
from_email = os.environ.get('FROM_EMAIL')
to_email = os.environ.get('TO_EMAIL')
Expiry_days = int(os.environ.get('EXPIRY_DAYS', 90)) # Default to 90 days if not set
reminder_email_age = Expiry_days - 5
def get_users():
iam_client = boto3.client('iam')
response = iam_client.list_users()
return [user['UserName'] for user in response['Users']]
def get_access_keys_age(username):
iam_client = boto3.client('iam')
response = iam_client.list_access_keys(UserName=username).get('AccessKeyMetadata', [])
access_keys_info = []
for item in response:
if item['Status'] == 'Active':
access_key_id = item['AccessKeyId']
create_date = item['CreateDate'].date()
age = (date.today() - create_date).days
access_keys_info.append((access_key_id, age))
return access_keys_info
def if_key_expired(username, access_key_id, age, reminder_email_age):
if age >= reminder_email_age:
return f'''
<p>Reminder: Access key <strong>{access_key_id}</strong> is <strong>{age}</strong> days old. Please rotate it.</p>
<p>For more details, visit the <a href="https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users/details/{username}?section=security_credentials"> Rotate this key here</a>.</p>
return None
def process_users():
email_body_list = []
users = get_users()
for user in users:
access_keys_info = get_access_keys_age(user)
for keys in access_keys_info:
access_key_id, age = keys
email_body = if_key_expired(user, access_key_id, age, reminder_email_age)
if email_body:
email_body_list.append(email_body)
return email_body_list
def build_email_message(to_email, from_email, subject, body):
msg = MIMEMultipart()
msg['From'] = from_email
msg['To'] = to_email
msg['Subject'] = subject
body_part = MIMEText(body, 'html')
msg.attach(body_part)
return msg
def send_email(msg, to_emails):
ses_client = boto3.client('ses')
response = ses_client.send_raw_email(
Source=msg["From"],
Destinations=to_emails,
RawMessage={"Data": msg.as_string()},
)
return response.get('MessageId', None)
def main(event, context):
subject = f"AWS Access Key Rotation Reminder"
for email_body in process_users():
email_msg = build_email_message(to_email, from_email, subject, email_body)
email_sent =send_email(email_msg, [to_email])
print(f"Email sent with Message ID: {email_sent}")
And updated the Terraform resource for Lambda
# lambda
resource "aws_lambda_function" "my_lambda_function" {
function_name = "iam-key-rotation"
role = aws_iam_role.lambda_role.arn
handler = "lambda_function.lambda_handler"
runtime = "python3.13"
timeout = 60
memory_size = 128
# Use the Archive data source to zip the code
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
environment {
variables = {
"to_email" = "[email protected]"
"from_email" = "[email protected]"
"Expiry_days" = 20
}
}
✅ You can find the entire code for this blog post on my public GitHub repo.
This blog is originally published on my personal blog, livingdevops
Let's connect
- Connect with me on Linkedin
**
**- Connect with me on twitter (@livingdevops)
Top comments (3)
I like your solution, but it's pretty involved with a lot of moving parts that could fail for a variety of reasons. Here's an option that is "more" Serverless, IMHO, and will get roughly the same result without any real concern about Lambda concurrency limits, SES sandbox mode, SES email sending limits :-)
Also, since we just publish findings to SNS, you can subscribe your email (shown here) or you could subscribe it to a Slack channel and get immediate ops ( docs.aws.amazon.com/chatbot/latest... ) -- Happy Clouding
Of course, we can do that. For my needs, a simple email rotation was enough as it our team of 4 was only one using those keys, so I used SES.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.