The goal
Every account collects roles nobody assumes, access keys nobody rotates and policies that grant s3:* when the workload only calls GetObject. An auditor or a leaked-key incident is usually what makes you clean this up.
IAM Access Analyzer has an unused access analyzer type that does the work for you. It checks IAM roles and users on a schedule and creates findings for:
| Finding type | What it means |
|---|---|
UnusedIAMRole | Role not assumed within the tracking period |
UnusedIAMUserAccessKey | Access key not used within the tracking period |
UnusedIAMUserPassword | Console password not used within the tracking period |
UnusedPermission | Services or actions granted to a role or user but not used |
This post sets up the analyzer, pulls the findings with the CLI, double-checks them, and removes access without breaking anything.
Note
Unused access analysis is a paid feature, charged per IAM role and user analyzed each month. Check the current IAM Access Analyzer pricing before you enable it across a large organization. External access analyzers stay free.
Step 1: create the analyzer
You can have one unused access analyzer per account per Region. IAM is global, so one Region is enough. Pick your home Region and stick with it.
For a single account:
aws accessanalyzer create-analyzer \
--analyzer-name unused-access-90d \
--type ACCOUNT_UNUSED_ACCESS \
--configuration '{"unusedAccess":{"unusedAccessAge":90}}' \
--region eu-west-1
For a whole AWS Organization, run it from the management account or the delegated administrator for Access Analyzer:
aws accessanalyzer create-analyzer \
--analyzer-name org-unused-access-90d \
--type ORGANIZATION_UNUSED_ACCESS \
--configuration '{"unusedAccess":{"unusedAccessAge":90}}' \
--region eu-west-1
unusedAccessAge is the tracking period in days. 90 is a sensible start. A shorter period produces more findings, including roles that only run quarterly jobs.
Tip
The first scan takes a while to complete, so don't expect findings right away. Check that the analyzer is
ACTIVE:aws accessanalyzer list-analyzers --type ACCOUNT_UNUSED_ACCESS --query "analyzers[].{name:name,status:status,arn:arn}"
Step 2: list the findings
Use list-findings-v2. The older list-findings command does not return unused access findings.
ARN=$(aws accessanalyzer list-analyzers --type ACCOUNT_UNUSED_ACCESS \
--query "analyzers[0].arn" --output text)
aws accessanalyzer list-findings-v2 --analyzer-arn "$ARN" \
--filter '{"status":{"eq":["ACTIVE"]}}' \
--query "findings[].{type:findingType,resource:resource,account:resourceOwnerAccount,updated:updatedAt}" \
--output table
Count findings by type to size the job:
aws accessanalyzer list-findings-v2 --analyzer-arn "$ARN" \
--filter '{"status":{"eq":["ACTIVE"]}}' \
--query "findings[].findingType" --output text | tr '\t' '\n' | sort | uniq -c
Only unused roles:
aws accessanalyzer list-findings-v2 --analyzer-arn "$ARN" \
--filter '{"status":{"eq":["ACTIVE"]}}' \
--query "findings[?findingType=='UnusedIAMRole'].[id,resource]" --output text
Read the detail of one finding
aws accessanalyzer get-finding-v2 --analyzer-arn "$ARN" --id <finding-id>
For UnusedPermission, findingDetails lists each service namespace, the unused actions and when they were last used. For UnusedIAMUserAccessKey, it shows the key ID and its last-used date.
Step 3: confirm before you remove anything
A finding tells you something wasn't used during the tracking period. It doesn't tell you the thing isn't needed. Check each finding against IAM's own last-used data and against the people who own the workload.
Role last used:
aws iam get-role --role-name app-batch-role \
--query "Role.{created:CreateDate,lastUsed:RoleLastUsed}"
Access keys and passwords for every user: use the credential report.
aws iam generate-credential-report
aws iam get-credential-report --query Content --output text | base64 -d > cred-report.csv
# Columns include password_last_used, access_key_1_last_used_date, access_key_2_last_used_date
Which services a role actually calls:
JOB=$(aws iam generate-service-last-accessed-details \
--arn arn:aws:iam::111122223333:role/app-batch-role --query JobId --output text)
aws iam get-service-last-accessed-details --job-id "$JOB" \
--query "ServicesLastAccessed[].{svc:ServiceNamespace,last:LastAuthenticated}" --output table
Before you delete anything, look out for these false positives:
| Pattern | Why it looks unused | What to do |
|---|---|---|
| DR or break-glass role | Used only during incidents or drills | Archive the finding with a rule, document the owner |
| Annual or quarterly job role | Runs less often than the tracking period | Raise unusedAccessAge or archive with a rule |
| Service-linked roles | Created and used by AWS services | Leave them; delete only through the owning service |
| Role assumed only cross-account during audits | Rare but legitimate | Confirm with the auditing team |
Step 4: remove access safely
Work in stages so you can roll back each change.
Unused access keys: deactivate, wait, then delete
aws iam update-access-key --user-name ci-legacy --access-key-id AKIA... --status Inactive
# Wait one to two weeks for complaints, then:
aws iam delete-access-key --user-name ci-legacy --access-key-id AKIA...
Warning
A deleted access key cannot be restored. Deactivating it first gives you a one-command rollback (
--status Active) if a forgotten script breaks.
Unused console passwords
aws iam delete-login-profile --user-name old-contractor
Unused roles: block, then delete
Remove the trust policy's principals, or attach a deny-all inline policy first. This gives you a reversible step before you delete the role.
aws iam put-role-policy --role-name app-batch-role --policy-name quarantine-deny-all \
--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"*","Resource":"*"}]}'
When nothing breaks after the agreed wait, detach the managed policies, delete the inline policies and instance profiles, then delete the role:
R=app-batch-role
for p in $(aws iam list-attached-role-policies --role-name $R --query "AttachedPolicies[].PolicyArn" --output text); do
aws iam detach-role-policy --role-name $R --policy-arn $p; done
for p in $(aws iam list-role-policies --role-name $R --query "PolicyNames[]" --output text); do
aws iam delete-role-policy --role-name $R --policy-name $p; done
for ip in $(aws iam list-instance-profiles-for-role --role-name $R --query "InstanceProfiles[].InstanceProfileName" --output text); do
aws iam remove-role-from-instance-profile --instance-profile-name $ip --role-name $R; done
aws iam delete-role --role-name $R
Caution
If the role was created by CloudFormation or Terraform, remove it in the template instead. Deleting it by hand causes drift and can fail the next stack update.
Unused permissions: tighten the policy
For UnusedPermission findings, Access Analyzer can draft a tighter policy for you:
aws accessanalyzer generate-finding-recommendation --analyzer-arn "$ARN" --id <finding-id>
aws accessanalyzer get-finding-recommendation --analyzer-arn "$ARN" --id <finding-id>
Review the suggested policy, apply it through your IaC pipeline, and keep the old version so you can roll back.
Step 5: archive what you've accepted
Close findings you've reviewed and accepted so the list only shows new work:
aws accessanalyzer update-findings --analyzer-arn "$ARN" --status ARCHIVED --ids <id1> <id2>
For repeating patterns, such as all break-glass roles, create an archive rule:
aws accessanalyzer create-archive-rule --analyzer-name unused-access-90d \
--rule-name breakglass-roles \
--filter '{"resource":{"contains":["role/breakglass-"]}}'
Prevent it next time
- Keep the analyzer running and review new findings monthly, not once a year.
- Send findings to Security Hub or EventBridge so they reach the owning team's queue.
- Tag every role with an
ownerso each finding has someone who can answer "is this still needed?" - Replace long-lived access keys with roles, IAM Identity Center or OIDC federation, so
UnusedIAMUserAccessKeyfindings go away for good. - Generate new policies from CloudTrail activity (Access Analyzer policy generation) rather than starting from
*.