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 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" = "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

👉 Terraform To Deploy AWS Lambda Function With S3 Trigger

Akhilesh Mishra

Akhilesh Mishra

I am Akhilesh Mishra, a self-taught Devops engineer with 11+ years working on private and public cloud (GCP & AWS)technologies.

I also mentor DevOps aspirants in their journey to devops by providing guided learning and Mentorship.

Topmate: https://topmate.io/akhilesh_mishra/