How can I find unused AWS IAM roles for security hardening using Python?
Quick Answer
Use boto3 to paginate through list_roles, read each role's RoleLastUsed.LastUsedDate, falling back to CreateDate if the role has never been used, and flag anything older than a threshold like 90 days while explicitly skipping AWS service-linked roles. The script then retrieves a Slack webhook URL from Secrets Manager at runtime and posts a summary, rather than hardcoding a webhook into source code.
Detailed Answer
This is like a building security audit that reviews every employee badge and flags anyone who hasn't swiped into the building in three months — not because they're necessarily a threat, but because an unused badge that still works is unnecessary risk sitting around, and if it's ever stolen or misused, nobody would notice quickly since nobody was using it anyway.
This script exists because IAM roles accumulate over the life of an AWS account far faster than they get cleaned up — a role created for a one-time migration, a proof-of-concept that never shipped, or a service that was decommissioned without anyone remembering to remove its role, all continue to exist as valid, assumable identities indefinitely. Security teams treat unused roles as attack surface because every role is a potential path for privilege escalation or lateral movement if its trust policy or attached permissions are ever exploited, and reducing that surface area is one of the most effective, low-risk security improvements available.
The script paginates through list_roles, necessary because accounts with hundreds of roles would otherwise be truncated, and for each role calls get_role to access the RoleLastUsed field, which AWS populates based on actual AssumeRole activity — critically, this field can be entirely absent for a role that has genuinely never been assumed, which is why the script checks both RoleLastUsed and falls back to CreateDate for roles with no usage history at all. It explicitly skips roles under the /aws-service-role/ path, since these are managed by AWS services themselves and aren't meant to be manually cleaned up. For notification, rather than embedding a Slack webhook URL directly in code or an environment variable, both of which risk leaking a live webhook into logs or version control, it fetches the webhook from AWS Secrets Manager at runtime, keeping the actual credential out of the deployment artifact entirely.
In production, this runs as a scheduled Lambda triggered by EventBridge on a recurring cadence, with its execution role scoped narrowly to list roles, get role, and get-secret-value on the one specific secret it needs — a common mistake is granting broader Secrets Manager access than the single secret required. What to monitor: the trend in flagged-role count over time, since a sudden jump usually means a team's automation started creating throwaway roles that never got attached to a cleanup process, and Slack delivery failures themselves, since a security report that fails to send silently is functionally the same as a security report that was never generated.
The non-obvious gotcha is that RoleLastUsed data is not updated in real time — AWS documents it as being updated within a matter of hours, not instantly — so a role assumed minutes before the scan runs can still show as last-used much earlier than expected, making a strict cutoff of exactly 90 days occasionally flag a role that's actually in active, if infrequent, use. Additionally, usage tracked in one region does not always reflect activity that occurred through another region's endpoint for the same global IAM role, which can make an actively-used role appear falsely idle depending on which region's data the check happens to observe. Mature versions build in a small buffer and cross-check with CloudTrail AssumeRole events for roles near the threshold boundary before treating them as confirmed unused.