DEV Community

Cover image for Building a production Lambda function that monitors IAM access keys and sends automated email alerts using boto3 and AWS SES.
Akhilesh Mishra for AWS Community Builders

Posted on • Originally published at livingdevops.com

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.

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.

  1. List the users and access keys
  2. Calculate the age
  3. Compare it with age standards, and determine if this key should be rotated.
  4. If the key should be rotated, build the email body that includes the link to the user
  5. Send the email using AWS SES(Simple Email Service)
  6. Building the code
  7. 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

Enter fullscreen mode Exit fullscreen mode

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())

Enter fullscreen mode Exit fullscreen mode

Image description

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'))
Enter fullscreen mode Exit fullscreen mode

Image description

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))

Enter fullscreen mode Exit fullscreen mode

That HTML will look like this

Image description

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

I will go to Lambda and test it. I should get an email.
And here it is

Image description

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}")
Enter fullscreen mode Exit fullscreen mode

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

Image description

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

Image description

- 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"
}
Enter fullscreen mode Exit fullscreen mode

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"
        }
      }
    ]
  })    

}
Enter fullscreen mode Exit fullscreen mode

- 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 = "*"
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode

- 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
}
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we can deploy the lambda function

terraform init
terraform plan
terraform apply
Enter fullscreen mode Exit fullscreen mode

Image description

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}")
Enter fullscreen mode Exit fullscreen mode

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
      }
    }   
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
jessefarinacci profile image
Jesse • Edited

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

resource "aws_sns_topic"               "t" { name = "t" }
resource "aws_sns_topic_policy"        "p" { topic = t, policy = "allow AWS *" }
resource "aws_sns_topic_subscription"  "s" { topic = t, proto = EMAIL, endpoint = "[email protected]" }
resource "aws_cloudwatch_event_rule"   "r" { pattern = jsonencode({ "source" = ["aws.config"] }) }
resource "aws_cloudwatch_event_target" "t" { rule = event.r, target = topic.t }
# https://docs.aws.amazon.com/config/latest/developerguide/access-keys-rotated.html
resource "aws_config_config_rule"      "r" {
  input     = jsonencode({ maxAccessKeyAge = "90" })
  frequency = "TwentyFour_Hours"
  name      = "access-keys-rotated"
  mode      { mode = "DETECTIVE" }
  scope     { compliance_resource_types = ["AWS::IAM::User"] }
  source    { owner = AWS, source = "ACCESS_KEYS_ROTATED" }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
livingdevops profile image
Akhilesh Mishra

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.