To catch expiring IAM access keys, list every key with its creation date, flag keys older than 90 days, and check last-used dates before rotating. The Bash audit script below does all three in one read-only pass.

Stale access keys are how quiet breaches happen. A key created for a migration in 2024 still works in 2026 if nobody rotates it, and the owner left the team two reorgs ago. AWS gives you the metadata to find these keys; few teams check it on a schedule.

This guide shows a read-only Bash audit that lists all IAM keys, flags old ones, and surfaces never-used keys for removal. It changes nothing in your account. Rotation itself is a separate, deliberate step.

Quick Answer: How Do You Find Expiring and Stale IAM Access Keys?

Run aws iam list-access-keys to get each key’s creation date and status, then aws iam get-access-key-last-used --access-key-id AKIA... to see when it was last active. The script in the audit script section loops over all users, flags keys older than 90 days, and highlights keys with no recorded use.

What the AWS Commands Return

Two documented IAM operations carry the whole audit. list-access-keys returns AccessKeyMetadata entries with UserName, AccessKeyId, Status (Active, Inactive, or Expired), and CreateDate. get-access-key-last-used returns LastUsedDate, ServiceName, and Region for one key. Note the documented edge cases: LastUsedDate is null when a key exists but has never been used since tracking began, and ServiceName reads “N/A” in the same situation. A null date means unused, not broken.

Prerequisites

  • AWS CLI v2 installed and configured with a profile that can read IAM. Verify with aws sts get-caller-identity.
  • IAM read permissions: iam:ListUsers, iam:ListAccessKeys, iam:GetAccessKeyLastUsed. No write permissions are needed for the audit.
  • Bash on Linux or macOS. The script uses date -d (GNU); macOS needs coreutils or the adjustment noted below.

The Read-Only Audit Script

This script only calls list and get operations. It creates, modifies, and deletes nothing. Syntax-checked with bash -n; AWS responses below follow the documented shapes from the CLI reference (checked 2026-09-20, CLI 2.36.x). Run it against your own account; do not run write operations to “test” this article.

#!/usr/bin/env bash
# iam-key-audit.sh: read-only report of IAM access keys by age and use.
# Usage: ./iam-key-audit.sh [max_age_days] [profile]
set -euo pipefail

MAX_AGE_DAYS="${1:-90}"
PROFILE="${2:-default}"
NOW_EPOCH=$(date +%s)

users=$(aws iam list-users --query 'Users[].UserName' 
  --output text --profile "$PROFILE")

printf '%-25s %-22s %-10s %-8s %sn' "USER" "ACCESS_KEY_ID" "AGE_DAYS" "STATUS" "LAST_USED"

for user in $users; do
  keys=$(aws iam list-access-keys --user-name "$user" 
    --query 'AccessKeyMetadata[].[AccessKeyId,Status,CreateDate]' 
    --output text --profile "$PROFILE")
  [ -z "$keys" ] && continue
  echo "$keys" | while read -r key_id status created; do
    created_epoch=$(date -d "$created" +%s)
    age_days=$(( (NOW_EPOCH - created_epoch) / 86400 ))
    last_used=$(aws iam get-access-key-last-used --access-key-id "$key_id" 
      --query 'AccessKeyLastUsed.LastUsedDate' 
      --output text --profile "$PROFILE")
    [ "$last_used" = "None" ] && last_used="NEVER_USED"
    flag=""
    [ "$age_days" -gt "$MAX_AGE_DAYS" ] && flag="OLD"
    [ "$last_used" = "NEVER_USED" ] && flag="$flag UNUSED"
    printf '%-25s %-22s %-10s %-8s %s %sn' 
      "$user" "$key_id" "$age_days" "$status" "$last_used" "$flag"
  done
done

Sample output shape (values illustrative, field layout real):

USER                      ACCESS_KEY_ID          AGE_DAYS   STATUS   LAST_USED
deploy-bot                AKIAIOSFODNN7EXAMPLE   812        Active   2024-02-11T09:00:00Z OLD
deploy-bot                AKIAI44QH8DHBEXAMPLE   41         Active   NEVER_USED  UNUSED

Rotation Runbook: What to Do With the Report

  1. Triage UNUSED first. A key with no recorded use is either dead weight or attached to a job nobody documented. Confirm with the owning team, then delete it. One key per check, never in bulk.
  2. Rotate OLD keys one at a time. Create the replacement key, deploy it to the workload, confirm traffic on the new key (its LastUsedDate updates), then deactivate and finally delete the old key. AWS documents this create-deploy-deactivate-delete order in its guidance on managing access keys.
  3. Never deactivate both keys of a workload at once. Each IAM user can hold two keys precisely so rotation is hitless. Use the overlap.
  4. Record the rotation. Append the date, key suffix, and owner to your team runbook so the next audit has context.

Automating the Check

Run the script monthly from a scheduler, not from a laptop. Either an EventBridge-scheduled Lambda with IAM read rights or a cron entry on a hardened admin host works. Alert on any row flagged OLD or UNUSED. Keep the history: append each run to a CSV so age trends are visible instead of recomputed from memory.

On macOS, replace date -d "$created" +%s with date -j -f "%Y-%m-%dT%H:%M:%SZ" "$created" +%s, or install coreutils for GNU date.

Common Mistakes

  • Deleting a key before its replacement serves traffic. Always confirm LastUsedDate on the new key first.
  • Treating “N/A” last-used as an API error. It is the documented value for never-used keys. Treat it as a removal candidate, not a bug.
  • Auditing only humans. Service users and CI users (deploy-bot, ci-runner) hold the oldest keys. The script covers all users for this reason.
  • Storing the report with full key IDs in chat logs. Key IDs are not secrets, but keep reports in your ticket system, not in public channels.

Troubleshooting

  • AccessDenied on list-users: your profile lacks iam:ListUsers. Ask your administrator for read-only IAM audit rights or run with an audit role.
  • Empty output, exit 0: the account truly has no IAM users with keys (possible with SSO-only setups). Confirm with aws iam list-users --output table.
  • Pagination warnings on large accounts: the CLI paginates list-access-keys automatically; pass --no-paginate only when debugging a single call.

Security Considerations

Access-key IDs in this report are identifiers, not credentials; the secret key is shown only at creation time and can never be listed again. Still, treat the report as sensitive, transmit it over authenticated channels, and never commit keys or secrets to git. If a secret was ever committed, rotate the key immediately rather than auditing around it.

Alternatives

  • AWS IAM Access Analyzer and credential reports for console-based review without scripts.
  • Short-lived credentials (instance roles, OIDC for CI) to remove long-lived keys entirely where the workload supports it.
  • AWS Config rules such as checks for unused credentials when you need continuous compliance evidence rather than a monthly script.

FAQ

How often should IAM access keys be rotated?

A common baseline is 90 days for human users and shorter for high-risk workloads, but the right number comes from your policy and audit findings. This script defaults to flagging keys older than 90 days; adjust the threshold to match your written standard.

What does it mean when LastUsedDate is empty?

Per AWS documentation, a null date means the key has not been used since IAM began tracking, or there is no sign-in data. Treat such keys as removal candidates after confirming no workload depends on them.

Can this script modify or delete anything?

No. It calls only list-users, list-access-keys, and get-access-key-last-used. Rotation and deletion are manual follow-ups by design.

Does this work with SSO-only accounts?

If nobody uses long-term access keys, the report is legitimately empty. That is a good outcome. Keep the scheduled check so regressions (a newly created static key) get caught.

Related DevStackIO Resources

Method note: AWS CLI shapes verified against the official CLI reference for iam list-access-keys and iam get-access-key-last-used (v2.36.x, checked 2026-09-20). The script was syntax-checked with bash -n; it was not executed against a live AWS account for this draft, so sample output is illustrative. Test in a sandbox account before scheduling.

Related Tools

Try these companion tools on DevStackIO Tools: