AWS Lambda Tutorial: Build a real world devops project with Python & Terraform
Building a production Lambda function that monitors IAM access keys and sends automated email alerts using boto3 and AWS SES.
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.
Learn AWS Lambda by Solving a $1M Security Problem
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.
👉 If you are new to Lambda functions, then read this blog post.
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 = "akhileshmishratoemail@gmail.com"
from_email = "akhileshmishrafromemail@gmail.com"
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}")


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 = "aditiyamishranit@gmail.com"
from_email = "akhileshmishra121990@gmail.com"
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 The main
function will be the entry function for lambda.
AWS SES Part
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 secondshandler
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" = "akhileshmishratoemail@gmail.com"
"from_email" = "akhileshmishra1from@gmail.com"
"Expiry_days" = 20
}
}
✅ You can find the entire code for this blog post on my public GitHub repo.
This is all for this blog post. Let me know your thoughts in the comments.
If you liked this blog post, then you will love this one too