Skip to main content
Cloud Security · lesson 7 of 8
Cloud Security intermediate Lesson 7 of 8

Also part of: AWS

Auditing the AWS IAM Credential Report with Python

Parse an AWS IAM credential report with Python's csv module: find active root keys, users without MFA, keys over 90 days old and unused credentials.

By SkillByExample · Published

cloud-security aws iam python audit

The IAM credential report is the quickest account-wide view of who can log in and with what. It is a CSV with one row per IAM user plus the root user, and AWS generates it on request. Python’s csv module and a fixed “today” turn it into a repeatable audit for root keys, missing MFA, old keys and credentials nobody uses.

Tested on 2026-10-15 with Python 3.12.15 and the standard library only. The report below is a sample written for this lesson, not a real account. It uses the documented column layout and value formats, and 111122223333, the example account ID from AWS’s own documentation. Save it as credential-report.csv:

user,arn,user_creation_time,password_enabled,password_last_used,password_last_changed,password_next_rotation,mfa_active,access_key_1_active,access_key_1_last_rotated,access_key_1_last_used_date,access_key_1_last_used_region,access_key_1_last_used_service,access_key_2_active,access_key_2_last_rotated,access_key_2_last_used_date,access_key_2_last_used_region,access_key_2_last_used_service,cert_1_active,cert_1_last_rotated,cert_2_active,cert_2_last_rotated
<root_account>,arn:aws:iam::111122223333:root,2021-04-02T10:15:00+00:00,not_supported,2026-09-30T08:12:44+00:00,not_supported,not_supported,false,true,2023-02-11T09:00:00+00:00,2026-10-14T22:41:00+00:00,us-east-1,s3,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A
alice,arn:aws:iam::111122223333:user/alice,2022-01-10T09:00:00+00:00,true,2026-10-14T17:05:31+00:00,2026-07-01T09:30:00+00:00,N/A,true,true,2026-08-01T12:00:00+00:00,2026-10-14T06:20:00+00:00,eu-west-1,ec2,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A
bob,arn:aws:iam::111122223333:user/bob,2023-05-22T14:30:00+00:00,true,2026-10-10T11:47:02+00:00,2026-05-22T14:31:00+00:00,N/A,false,false,N/A,N/A,N/A,N/A,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A
ci-deploy,arn:aws:iam::111122223333:user/ci-deploy,2024-02-01T08:00:00+00:00,false,N/A,N/A,N/A,false,true,2025-11-20T08:00:00+00:00,2026-10-14T23:59:10+00:00,us-east-1,lambda,true,2026-09-01T08:00:00+00:00,N/A,N/A,N/A,false,N/A,false,N/A
carol,arn:aws:iam::111122223333:user/carol,2024-09-03T10:00:00+00:00,true,no_information,2024-09-03T10:00:00+00:00,N/A,false,false,N/A,N/A,N/A,N/A,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A
dave,arn:aws:iam::111122223333:user/dave,2022-06-15T09:00:00+00:00,true,2026-03-02T16:20:00+00:00,2025-12-01T09:00:00+00:00,N/A,true,true,2025-01-15T09:00:00+00:00,2026-02-27T13:10:00+00:00,us-east-1,s3,false,N/A,N/A,N/A,N/A,false,N/A,false,N/A

In a real account, aws iam generate-credential-report builds the report, and aws iam get-credential-report --query Content --output text | base64 -d > credential-report.csv downloads it. The API returns the CSV base64-encoded.

Read the report

csv.DictReader turns each row into a dict keyed by the header, so columns are read by name and not by position:

# peek.py: one line per identity in an IAM credential report
import csv
import sys

with open(sys.argv[1], newline="") as f:
    rows = list(csv.DictReader(f))

print(f"{len(rows)} rows, {len(rows[0])} columns")
for row in rows:
    print(f"{row['user']:15} password={row['password_enabled']:13} "
          f"mfa={row['mfa_active']:5} key1={row['access_key_1_active']:5} "
          f"key2={row['access_key_2_active']}")
$ python peek.py credential-report.csv
6 rows, 22 columns
<root_account>  password=not_supported mfa=false key1=true  key2=false
alice           password=true          mfa=true  key1=true  key2=false
bob             password=true          mfa=false key1=false key2=false
ci-deploy       password=false         mfa=false key1=true  key2=true
carol           password=true          mfa=false key1=false key2=false
dave            password=true          mfa=true  key1=true  key2=false

Every value is a string, including "true" and "false", so compare with == "true", never as a bool, because bool("false") is True. The root user is a row like any other, but its password_enabled is not_supported, not true. That will matter later.

Two ways the date maths breaks

Access-key age is “today minus access_key_1_last_rotated”. “Today” comes from the command line, so the audit gives the same answer whenever it runs:

# key_age.py: how old is each user's first access key?
import csv
import sys
from datetime import datetime

today = datetime.fromisoformat(sys.argv[2])  # pinned, e.g. 2026-10-15

with open(sys.argv[1], newline="") as f:
    for row in csv.DictReader(f):
        rotated = datetime.fromisoformat(row["access_key_1_last_rotated"])
        print(row["user"], (today - rotated).days, "days")
$ python key_age.py credential-report.csv 2026-10-15
Traceback (most recent call last):
  File "/lab/key_age.py", line 11, in <module>
    print(row["user"], (today - rotated).days, "days")
                        ~~~~~~^~~~~~~~~
TypeError: can't subtract offset-naive and offset-aware datetimes

The report’s timestamps end in +00:00, so they parse to timezone-aware datetimes. 2026-10-15 has no offset, so it parses to a naive one, and Python refuses to guess how they relate. Give “today” an explicit UTC timezone:

# key_age.py: how old is each user's first access key? (today in UTC)
import csv
import sys
from datetime import datetime, timezone

today = datetime.fromisoformat(sys.argv[2]).replace(tzinfo=timezone.utc)

with open(sys.argv[1], newline="") as f:
    for row in csv.DictReader(f):
        rotated = datetime.fromisoformat(row["access_key_1_last_rotated"])
        print(row["user"], (today - rotated).days, "days")
$ python key_age.py credential-report.csv 2026-10-15
<root_account> 1341 days
alice 74 days
Traceback (most recent call last):
  File "/lab/key_age.py", line 10, in <module>
    rotated = datetime.fromisoformat(row["access_key_1_last_rotated"])
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: Invalid isoformat string: 'N/A'

Two rows work, then bob has no access key, and the report says so with N/A where a timestamp would be. The documented placeholders are N/A (no such credential, or never used), no_information (a password never used) and not_supported (root’s password fields). None of them is a date, and each means something different from “old”. A small module handles both problems once:

# report.py: load an IAM credential report and turn its timestamps into ages
import csv
from datetime import datetime, timezone

NO_DATE = {"N/A", "no_information", "not_supported"}


def load(path):
    with open(path, newline="") as f:
        return list(csv.DictReader(f))


def parse_today(text):
    """'2026-10-15' -> midnight UTC, comparable with the report's timestamps."""
    return datetime.fromisoformat(text).replace(tzinfo=timezone.utc)


def days_since(value, today):
    """Whole days from a report timestamp to today, or None when there is no date."""
    if value in NO_DATE:
        return None
    return (today - datetime.fromisoformat(value)).days

days_since returns None for “no date”, so each check decides what a missing date means. An active key with no last-used date has never been used. That is a finding, not a crash.

Audit the access keys

Three rules from the usual IAM baselines: no access keys on the root user, rotate keys within 90 days, and remove keys that aren’t used:

# audit_keys.py: root keys, old keys and unused keys in a credential report
import sys
from report import days_since, load, parse_today

MAX_DAYS = 90
today = parse_today(sys.argv[2])

for row in load(sys.argv[1]):
    user = row["user"]
    for n in (1, 2):
        if row[f"access_key_{n}_active"] != "true":
            continue
        if user == "<root_account>":
            print(f"CRITICAL {user}: access key {n} is active")
        age = days_since(row[f"access_key_{n}_last_rotated"], today)
        if age > MAX_DAYS:
            print(f"HIGH     {user}: access key {n} is {age} days old")
        idle = days_since(row[f"access_key_{n}_last_used_date"], today)
        if idle is None:
            print(f"MEDIUM   {user}: access key {n} has never been used")
        elif idle > MAX_DAYS:
            print(f"MEDIUM   {user}: access key {n} unused for {idle} days")
$ python audit_keys.py credential-report.csv 2026-10-15
CRITICAL <root_account>: access key 1 is active
HIGH     <root_account>: access key 1 is 1341 days old
HIGH     ci-deploy: access key 1 is 328 days old
MEDIUM   ci-deploy: access key 2 has never been used
HIGH     dave: access key 1 is 637 days old
MEDIUM   dave: access key 1 unused for 229 days

The root key is the headline: it can do anything in the account, IAM policies can’t limit it, and the report shows it was used yesterday from us-east-1. ci-deploy shows a rotation that was never finished. A second key was created six weeks ago but never used, while the 328-day-old key is still doing the work. dave hasn’t used his key since February, which usually means someone left. Inactive keys are skipped, because a disabled key can’t be used. age is never None for an active key, because every active key has a rotation date.

Audit console access

The console checks are MFA on every password user, and passwords that are never used:

# audit_console.py: console passwords without MFA, and passwords nobody uses
import sys
from report import days_since, load, parse_today

MAX_DAYS = 90
today = parse_today(sys.argv[2])

for row in load(sys.argv[1]):
    user = row["user"]
    if row["password_enabled"] != "true":
        continue
    if row["mfa_active"] != "true":
        print(f"HIGH     {user}: console password without MFA")
    idle = days_since(row["password_last_used"], today)
    if idle is None:
        print(f"MEDIUM   {user}: password never used")
    elif idle > MAX_DAYS:
        print(f"MEDIUM   {user}: password unused for {idle} days")
$ python audit_console.py credential-report.csv 2026-10-15
HIGH     bob: console password without MFA
HIGH     carol: console password without MFA
MEDIUM   carol: password never used
MEDIUM   dave: password unused for 226 days

carol’s no_information became “never used”: an account created two years ago, never logged into and with no MFA, so anyone who learns the initial password gets a fresh, unmonitored login. dave shows up again, now through his password. Look at what’s missing, though. The root user has mfa_active=false and doesn’t appear, because its password_enabled is not_supported, so the first if skipped it. The first practice task fixes that.

Practice

  1. Make audit_console.py report the root user’s missing MFA, as CRITICAL.
Solution
# audit_console.py: console passwords without MFA (root included), and unused passwords
import sys
from report import days_since, load, parse_today

MAX_DAYS = 90
today = parse_today(sys.argv[2])

for row in load(sys.argv[1]):
    user = row["user"]
    if user == "<root_account>":
        if row["mfa_active"] != "true":
            print(f"CRITICAL {user}: no MFA on the root user")
        continue
    if row["password_enabled"] != "true":
        continue
    if row["mfa_active"] != "true":
        print(f"HIGH     {user}: console password without MFA")
    idle = days_since(row["password_last_used"], today)
    if idle is None:
        print(f"MEDIUM   {user}: password never used")
    elif idle > MAX_DAYS:
        print(f"MEDIUM   {user}: password unused for {idle} days")
$ python audit_console.py credential-report.csv 2026-10-15
CRITICAL <root_account>: no MFA on the root user
HIGH     bob: console password without MFA
HIGH     carol: console password without MFA
MEDIUM   carol: password never used
MEDIUM   dave: password unused for 226 days

The root user always has a password, so its row is handled before the password_enabled test. A check that filters on a column value can silently skip the row that matters most. Test each check against a row you expect it to flag.

  1. Write stale_users.py: list identities where every enabled credential (password and active keys) is unused for more than 90 days or has never been used. These are the deprovisioning candidates.
Solution
# stale_users.py: identities whose every enabled credential is idle
import sys
from report import days_since, load, parse_today

MAX_DAYS = 90
today = parse_today(sys.argv[2])

for row in load(sys.argv[1]):
    last_used = []
    if row["password_enabled"] == "true":
        last_used.append(row["password_last_used"])
    for n in (1, 2):
        if row[f"access_key_{n}_active"] == "true":
            last_used.append(row[f"access_key_{n}_last_used_date"])
    if not last_used:
        continue
    idle = [days_since(value, today) for value in last_used]
    if all(days is None or days > MAX_DAYS for days in idle):
        print(f"{row['user']}: idle {idle}")
$ python stale_users.py credential-report.csv 2026-10-15
carol: idle [None]
dave: idle [226, 229]

dave is idle on both his password and his key, and carol has never used her only credential. ci-deploy isn’t listed, even with a never-used key, because its other key was used yesterday. The per-credential checks above already cover that key.

  1. Run audit_keys.py with today set to 2027-01-01. Which findings are new, and why is pinning today the right default for an audit script?
Solution
$ python audit_keys.py credential-report.csv 2027-01-01
CRITICAL <root_account>: access key 1 is active
HIGH     <root_account>: access key 1 is 1419 days old
HIGH     alice: access key 1 is 152 days old
HIGH     ci-deploy: access key 1 is 406 days old
HIGH     ci-deploy: access key 2 is 121 days old
MEDIUM   ci-deploy: access key 2 has never been used
HIGH     dave: access key 1 is 715 days old
MEDIUM   dave: access key 1 unused for 307 days

alice and ci-deploy’s second key crossed 90 days. The data didn’t change, only the date did. A report is a snapshot, so the audit should be evaluated as of the time it was generated. With “today” as an argument, the same input always gives the same findings, and a test can pin the date. In production, pass the date of the report’s generation.

Frequently Asked Questions

What is the AWS IAM credential report?
The credential report is a CSV file that AWS IAM generates for an account. It has one row per IAM user plus one for the root user, with columns for whether a console password is enabled, whether MFA is active, and when each access key was rotated and last used. Auditors use it to find stale, unprotected or over-age credentials.
How do I download the IAM credential report?
Run aws iam generate-credential-report, then aws iam get-credential-report. The API returns the CSV base64-encoded in its Content field, so decode it, for example with --query Content --output text piped to base64 -d. AWS regenerates the report at most every four hours, and the caller needs the iam:GenerateCredentialReport and iam:GetCredentialReport permissions.
What do N/A, no_information and not_supported mean in the credential report?
They are placeholders where a timestamp would be. N/A means the credential doesn't exist or was never used, such as a user with no access key. no_information in password_last_used means the password has never been used, or not since AWS began tracking it in October 2014. not_supported appears for the root user's password_enabled field and some others. Treat all three as no date, not as parse errors.
Why does Python say can't subtract offset-naive and offset-aware datetimes?
The credential report's timestamps carry a UTC offset, such as +00:00, so datetime.fromisoformat returns timezone-aware datetimes. A date typed on the command line, or datetime.now() without a timezone, is naive. Python refuses to subtract one from the other. Give the naive value an explicit timezone, for example with replace(tzinfo=timezone.utc).
Why should the root user have no access keys?
The root user has unrestricted access to the whole account, and IAM policies can't limit it. An access key for root is a long-lived secret with that power, usable from anywhere without MFA. AWS recommends deleting root access keys and using IAM roles or users with scoped permissions for programmatic access. The credential report shows them in the access_key_1_active and access_key_2_active columns.