
If your AWS account has existed for more than a few months, there's a good chance a quarter of the permissions attached to your IAM roles have never been used even once. Nobody removes permissions because nobody can prove, just by reading a policy, whether removing an action will break something. IAM Access Analyzer's unused-access feature solves that by watching CloudTrail-derived usage data and telling you exactly which permissions, roles, and access keys have gone stale. This post walks through calling that feature directly from Python with boto3, so you can turn "probably unused" into a reviewable, auditable list.
1. What IAM Access Analyzer's unused-access check actually does
Most people know Access Analyzer as the feature that flags S3 buckets or IAM roles that are reachable from outside your account ("external access analysis"). Unused-access analysis is a separate analyzer type. Instead of looking at who can reach a resource, it looks at who has not used a permission in a configurable window — 7, 30, 60, 90, 180, or 365 days — and reports four kinds of findings:
- UnusedIAMRole — a role that hasn't been assumed in the window.
- UnusedIAMUserAccessKey — an access key that hasn't made an API call.
- UnusedIAMUserPassword — a console password that hasn't been used to sign in.
- UnusedPermission — individual actions granted by a policy that have never been exercised by the principal that holds them, even though the principal itself is active.
That last category is the interesting one for least-privilege work. A role can be assumed daily and still be carrying dozens of s3:* or iam:* actions it has never called. Unused-access analysis finds those specific actions, not just the whole policy.
2. Create the analyzer
You need an analyzer of type ACCOUNT_UNUSED_ACCESS (or ORGANIZATION_UNUSED_ACCESS if you're running this from a delegated admin account across an AWS Organization). This is a one-time setup step — the analyzer keeps running in the background and refreshing findings after that.
aws accessanalyzer create-analyzer \
--analyzer-name unused-access-analyzer \
--type ACCOUNT_UNUSED_ACCESS \
--configuration '{"unusedAccess": {"unusedAccessAge": 90}}'The same thing in Python, if you'd rather provision it as part of a setup script than run it from the CLI:
import boto3
client = boto3.client("accessanalyzer", region_name="us-east-1")
response = client.create_analyzer(
analyzerName="unused-access-analyzer",
type="ACCOUNT_UNUSED_ACCESS",
configuration={
"unusedAccess": {
"unusedAccessAge": 90 # days of inactivity before something is "unused"
}
},
)
print(response["arn"])It takes some time (often under an hour, sometimes longer on large accounts) for the analyzer to finish its first pass. Findings won't show up until that initial scan completes.
3. Pull findings with boto3
Once the analyzer has an ARN, you query its findings with list_findings_v2. This is the paginated, filterable call that replaced the older list_findings API for Access Analyzer's newer finding types. You filter by findingType and by status (findings default to ACTIVE; anything you've already reviewed and dismissed shows up as ARCHIVED).
import boto3
aa = boto3.client("accessanalyzer", region_name="us-east-1")
def get_analyzer_arn(name="unused-access-analyzer"):
for a in aa.list_analyzers(type="ACCOUNT_UNUSED_ACCESS")["analyzers"]:
if a["name"] == name:
return a["arn"]
raise RuntimeError("No ACCOUNT_UNUSED_ACCESS analyzer found")
def list_unused_permission_findings(analyzer_arn):
findings = []
paginator = aa.get_paginator("list_findings_v2")
for page in paginator.paginate(
analyzerArn=analyzer_arn,
filter={
"status": {"eq": ["ACTIVE"]},
"findingType": {"eq": ["UnusedPermission"]},
},
):
findings.extend(page["findings"])
return findings
if __name__ == "__main__":
arn = get_analyzer_arn()
for f in list_unused_permission_findings(arn):
print(f["id"], f["resource"], f["findingType"])Each row here is a summary. To see which actions are unused on a given role or user, you need to call get_finding_v2 with the finding's id.
4. Expand a finding into the actual unused actions
This is the part that turns a vague "this role has unused permissions" alert into something a human can act on: a concrete list of IAM actions nobody has called in 90 days, scoped to one principal.
def describe_unused_permission(analyzer_arn, finding_id):
detail = aa.get_finding_v2(analyzerArn=analyzer_arn, id=finding_id)
principal = detail.get("resource")
actions = detail.get("findingDetails", [{}])[0].get("unusedPermissionDetails", {}).get("actions", [])
return {
"principal": principal,
"finding_type": detail.get("findingType"),
"unused_actions": actions,
"last_analyzed": str(detail.get("analyzedAt")),
}
if __name__ == "__main__":
arn = get_analyzer_arn()
report = []
for f in list_unused_permission_findings(arn):
report.append(describe_unused_permission(arn, f["id"]))
for row in report:
print(f"{row['principal']}: {len(row['unused_actions'])} unused actions")
for action in row["unused_actions"]:
print(f" - {action}")Note that the exact shape of findingDetails varies by finding type — an UnusedIAMRole finding won't have an unusedPermissionDetails block at all, it just reports the role and the last-used timestamp. Always branch on findingType before you try to parse type-specific fields, or guard with .get() defaults like above.
5. Build a full account-wide audit report
Pulling this together into one script gives you a CSV you can hand to a security reviewer, or feed into a dashboard, without anyone opening the IAM console.
import csv
import boto3
aa = boto3.client("accessanalyzer", region_name="us-east-1")
FINDING_TYPES = ["UnusedIAMRole", "UnusedIAMUserAccessKey", "UnusedIAMUserPassword", "UnusedPermission"]
def get_analyzer_arn(name="unused-access-analyzer"):
for a in aa.list_analyzers(type="ACCOUNT_UNUSED_ACCESS")["analyzers"]:
if a["name"] == name:
return a["arn"]
raise RuntimeError("No ACCOUNT_UNUSED_ACCESS analyzer found")
def all_active_findings(analyzer_arn):
paginator = aa.get_paginator("list_findings_v2")
for page in paginator.paginate(
analyzerArn=analyzer_arn,
filter={"status": {"eq": ["ACTIVE"]}, "findingType": {"eq": FINDING_TYPES}},
):
yield from page["findings"]
def main():
arn = get_analyzer_arn()
rows = []
for f in all_active_findings(arn):
detail = aa.get_finding_v2(analyzerArn=arn, id=f["id"])
actions = []
for d in detail.get("findingDetails", []):
actions.extend(d.get("unusedPermissionDetails", {}).get("actions", []))
rows.append({
"finding_id": f["id"],
"finding_type": f["findingType"],
"resource": f["resource"],
"resource_type": f.get("resourceType", ""),
"unused_action_count": len(actions),
"unused_actions": ";".join(a.get("action", a) if isinstance(a, dict) else a for a in actions),
})
with open("unused_access_report.csv", "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys()) if rows else
["finding_id", "finding_type", "resource", "resource_type",
"unused_action_count", "unused_actions"])
writer.writeheader()
writer.writerows(rows)
print(f"Wrote {len(rows)} findings to unused_access_report.csv")
if __name__ == "__main__":
main()Run this on a schedule — a weekly cron job, a Lambda on an EventBridge rule, whatever fits your pipeline — and diff successive reports. A permission that's been unused for 90 days and stays unused for 180 is a very safe candidate for removal. One that disappears from the report because it just got used is a sign you'd have broken something if you'd removed it on week one.
6. Acting on findings: archive, don't delete blindly
Once you've reviewed a finding and decided the flagged access really is unused and safe to remove, you have two separate jobs: fix the actual IAM policy (outside Access Analyzer's scope — you do this with iam.delete_role_policy, iam.detach_role_policy, or by editing the policy document), and mark the finding as handled inside Access Analyzer so it stops showing up as active noise:
aa.update_findings(
analyzerArn=arn,
ids=[finding_id],
status="ARCHIVED",
)Archiving doesn't change IAM at all — it only changes Access Analyzer's bookkeeping. Treat it as "I've triaged this," not "I've fixed this." If the underlying permission is still attached and still unused 90 days later, a fresh scan will raise a new finding regardless of the old one's archived status.
Common pitfalls
- Querying before the first scan finishes. A freshly created analyzer can take a while to populate findings. An empty
list_findings_v2result right aftercreate_analyzerusually means "still scanning," not "nothing unused." - Treating service-linked roles like regular roles. Plenty of AWS-managed service-linked roles will show up as "unused" by access pattern even though AWS itself invokes them in ways CloudTrail doesn't fully surface to Access Analyzer. Cross-check before you touch anything with a
AWSServiceRoleForprefix. - Assuming
unusedAccessAgeis retroactive to account creation. It's a rolling window from "now." A 90-day window means "unused in the last 90 days," not "unused since this role was created." A role created 85 days ago won't be flagged yet even if it's never been touched. - Forgetting the region. IAM itself is global, but the Access Analyzer API is regional. You created the analyzer in one region; querying from another region with a default boto3 session and no explicit
region_namewill silently return nothing. - Archiving instead of fixing. Archiving a finding clears it from your dashboard. It does not remove the permission. Build your pipeline so archiving only happens after the policy change actually ships.
Troubleshooting
If create_analyzer throws AccessDeniedException, your IAM identity needs the access-analyzer:CreateAnalyzer permission, plus — for an organization-wide analyzer — delegated administrator status in AWS Organizations. If list_findings_v2 returns a validation error about the filter, double check you're passing a dict of {"eq": [...]} per field rather than a bare list; the filter syntax is stricter than the older list_findings API. If every finding's unusedPermissionDetails comes back empty even for findings you can confirm are UnusedPermission type in the console, check your boto3 version — this finding-detail schema was added after unused-access analysis first launched, and an old pinned boto3/botocore version may not deserialize the field correctly. Upgrade with pip install -U boto3 botocore and retry.
Where to go next
Once this is running reliably, the natural next step is wiring the CSV output into a Slack or email digest so a human reviews it weekly instead of you remembering to run the script. From there, look at IAM Access Analyzer's policy generation feature (start_policy_generation / get_generated_policy), which takes the same CloudTrail activity data and drafts a least-privilege policy for a role automatically — a good second pass once you've cleared out the obvious unused permissions by hand.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


