A 2018 Access Key. Still Active in Production. Here's the Python Script That Found It Across an Entire AWS Organization.

Published: (March 7, 2026 at 05:00 AM EST)
8 min read
Source: Dev.to

Source: Dev.to

Introduction

A few weeks ago I sat down to review the IAM state of a multi‑account AWS Organization. It wasn’t a formal audit with weeks of planning. It was the simple question every Cloud Security Engineer should be able to answer at any time:

Who has access, with what credentials, and since when?

The answer surprised me. Not because it was hard to find — but because of how easy it was to automate, and what came to light when I did.

Organizations that have been running on AWS for years accumulate security debt without realizing it. They start with one account, then two, then a team requests its own environment, another project comes along, and suddenly you have an AWS Organization with dozens of accounts, each with its own IAM history.

The problem isn’t scale — it’s visibility. Or rather, the lack of it.

In multi‑account environments, nobody has a consolidated view of who has what access.

  • Infrastructure teams know what they deployed.
  • Development teams know what they needed at the time.

But the Access Keys created three years ago for an integration process that no longer exists, that were never rotated, that are still active — those don’t show up in any dashboard. They don’t generate alerts. They don’t bother anyone. They just wait.

That’s exactly what I found: an active AWS Organization, with multiple production accounts, and credentials that had gone years without being centrally audited. Not because the team was careless — but because nobody had built the mechanism to see them all at once.

I decided to automate that search.

Why Access Keys Matter

IAM Access Keys are long‑lived credentials. Unlike IAM roles — which generate temporary credentials that expire automatically — an Access Key has no expiration date. If you create one today and never rotate it, it’s still valid in 2030.

That makes them one of the most common attack vectors in AWS environments. Not because they’re insecure by design, but because time works against them. A key that’s been active for years silently accumulates risk:

  • It may have been exposed in a code repository without anyone noticing.
  • It may be in the hands of an employee who no longer works at the organization.
  • It may have permissions granted for a one‑time project that were never reviewed.
  • It may have been used by an attacker for months — and without rotation, nobody knows.

The AWS Security Maturity Model v2 is precise about this.

  • Phase 1 – Quick Wins (Identity and Access Management domain): one of the key controls is Multi‑Factor Authentication — ensuring all users with console access have MFA enabled.
  • Phase 2 – Foundational: the model goes further with Use Temporary Credentials — the recommendation to migrate toward IAM roles and short‑lived credentials, moving away from long‑term Access Keys.

The script audits both controls in a single run: which users have active Access Keys, how long they’ve been active, and whether they have MFA configured. An active key from 2018 combined with a user without MFA isn’t just technical debt — it’s an attack surface that’s been open for years.

Design Decisions Before Code

Before writing a single line, I made three decisions that define how the script works.

  1. Cross‑account via STS, not IAM users
    sts:AssumeRole: the script assumes an existing role in each account, gets temporary credentials, audits, and the credentials expire on their own.

  2. Start from AWS Organizations – discover every member account automatically.

  3. Always paginateget_paginator(). In an account with many users you’ll only see the first results and never know it. It’s the kind of silent bug that destroys the reliability of an audit.

With those three decisions clear, the code practically writes itself.

The Main Flow

def main():
    args = parse_args()
    session = boto3.Session(profile_name=args.profile)
    org_client = session.client('organizations')

    # Get all active accounts from the Organization
    accounts = get_accounts(org_client)

    all_findings = []
    for account in accounts:
        print(f"Auditing account: {account['name']} ({account['id']})")
        try:
            credentials = assume_role(
                session,
                account['id'],
                args.role,
                'SecurityAudit'
            )
            iam_client = boto3.client(
                'iam',
                aws_access_key_id=credentials['AccessKeyId'],
                aws_secret_access_key=credentials['SecretAccessKey'],
                aws_session_token=credentials['SessionToken']
            )
            findings = get_iam_users_with_keys(iam_client, account['id'], account['name'])
            all_findings.extend(findings)
        except Exception as e:
            print(f"Error in account {account['name']}: {e}")

    return all_findings

One account fails — the script continues. That’s intentional: in a real Organization you’ll find accounts where the role isn’t deployed, or where permissions differ. The try/except ensures an error in one account doesn’t stop the entire audit.

The Heart of the Script – What We Audit per User

def get_iam_users_with_keys(iam_client, account_id, account_name):
    findings = []
    paginator = iam_client.get_paginator('list_users')

    for page in paginator.paginate():
        for user in page['Users']:
            # User's Access Keys
            keys_response = iam_client.list_access_keys(UserName=user['UserName'])

            # MFA status
            mfa_response = iam_client.list_mfa_devices(UserName=user['UserName'])
            mfa_devices = mfa_response['MFADevices']

            # Console access
            try:
                iam_client.get_login_profile(UserName=user['UserName'])
                password_status = 'Configured'
            except iam_client.exceptions.NoSuchEntityException:
                password_status = 'Not configured'

            for key in keys_response['AccessKeyMetadata']:
                last_used_response = iam_client.get_access_key_last_used(
                    AccessKeyId=key['AccessKeyId']
                )
                # Build a finding record
                finding = {
                    'AccountId': account_id,
                    'AccountName': account_name,
                    'UserName': user['UserName'],
                    'AccessKeyId': key['AccessKeyId'],
                    'KeyStatus': key['Status'],
                    'CreateDate': key['CreateDate'].isoformat(),
                    'LastUsedDate': last_used_response['AccessKeyLastUsed'].get('LastUsedDate'),
                    'LastUsedRegion': last_used_response['AccessKeyLastUsed'].get('Region'),
                    'LastUsedService': last_used_response['AccessKeyLastUsed'].get('ServiceName'),
                    'MFAEnabled': bool(mfa_devices),
                    'ConsolePassword': password_status,
                }
                findings.append(finding)

    return findings

If a single account fails, the script continues. That resilience is essential when dealing with large, heterogeneous Organizations.

Closing Thoughts

Automating IAM credential hygiene across an AWS Organization uncovers hidden risk that would otherwise sit unnoticed for years. By:

  • Assuming roles via STS (no permanent cross‑account users),
  • Starting from AWS Organizations (automatic account discovery), and
  • Paginating every API call (complete data),

you can reliably surface long‑lived Access Keys, missing MFA, and unused console passwords — the very items the AWS Security Maturity Model flags as quick wins and foundational controls.

Implementing this audit as a regular, scheduled job turns a one‑time discovery into continuous security hygiene, helping your organization stay ahead of the inevitable drift that comes with growth.

IAM Audit Script Overview

The script walks through every member account in an AWS Organization, assumes a role in each account, and gathers IAM‑related data in a single pass per account.

What the script captures for each user

ItemDescription
Access KeysAll keys with their status (Active/Inactive) and creation date
Last UsedWhen each key was last used and for which AWS service
MFAWhether MFA is enabled and its type (Virtual, Hardware, or None)
Console AccessWhether the user can sign‑in to the AWS Management Console

Running the script

python iam_audit.py --profile your-mgmt-profile --role OrganizationAccountAccessRole

Requirements

  1. AWS profile with permission to the management account.
  2. Assume‑role permission in each member account (the role is deployed in every child account).

Minimal IAM policy for the management account

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "organizations:ListAccounts",
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Resource": "arn:aws:iam::*:role/ROLE-NAME-IN-CHILD-ACCOUNTS"
    }
  ]
}

Only the permissions above are required.

If you use AWS Control Tower, the AWSControlTowerExecution role already exists in all accounts and can be used as a starting point. For production workloads, however, it’s recommended to create a dedicated audit role with read‑only IAM permissions.

Why the least‑privilege approach matters

Applying the principle of least privilege to the audit tool itself reduces its attack surface. The script never modifies IAM resources; it only reads them through the role it assumes in each child account.

Tracking remediation over time

Beyond the current state, the script queries CloudTrail in each account for the following IAM events:

  • DeleteAccessKey
  • CreateAccessKey
  • DeleteUser

These events let you see whether findings are being remediated or simply sitting in a report.

Output

The run produces two CSV files:

  1. IAM findings – a row per user/key with all captured attributes.
  2. CloudTrail events – a timeline of IAM changes to monitor remediation progress.

Sample Findings

FindingResult
Accounts auditedMore than 20 active accounts
Access Keys foundDozens
Oldest keyCreated in 2018 – still active in production
Users without MFA + console accessDozens
Total execution timeMinutes

Note: Two accounts were not audited because the audit role was missing. This itself is a finding – you can’t audit what you can’t access.

Key takeaway

The most impactful data point is age. An access key created in 2018 that remains active indicates a blind spot: it survived every change because no one was looking for it. The script uncovered this in minutes.

Next Steps

  1. Run the script in your own AWS Organization (Python + boto3 + audit role).
  2. Import the CSVs into a dashboard tool (e.g., QuickSight, Power BI) to visualize:
    • Risk widgets per account
    • Remediation trends over time
    • Consolidated MFA status
  3. Iterate – add more IAM events or integrate with a SIEM if desired.

Repository

The complete code is available on GitHub:

🔗 GitHub: gerardokaztro/iam-audit

About the Author

Gerardo Castro – AWS Security Hero & Cloud Security Engineer (LATAM)
He believes the best way to learn cloud security is by building real solutions, not just memorizing frameworks. Follow his posts on Hashnode:

🌐 Blog: roadtocloudsec.hashnode.dev

0 views
Back to Blog

Related posts

Read more »