· PathShield Team · Tutorials · 10 min read
How to Find and Delete Unused IAM Roles in AWS (Save Money & Improve Security)
Unused IAM roles are security risks and cost money. This guide shows you exactly how to find and safely remove them with automated scripts.
How to Find and Delete Unused IAM Roles in AWS (Save Money & Improve Security)
Your AWS account has 247 IAM roles. You recognize maybe 30 of them. The rest? Ghost roles from deleted projects, abandoned experiments, and that contractor who left 6 months ago. Each one is a potential security risk and they’re all costing you money in complexity and audit time.
Why Unused IAM Roles Are Dangerous
Security Risks:
- Forgotten roles often have overly broad permissions
- Old cross-account trust relationships remain active
- Attackers target unused roles (less likely to be monitored)
- Compliance auditors flag them every time
Hidden Costs:
- Slower IAM policy evaluations
- Complicated security audits
- Confusion during incident response
- Time wasted figuring out “what does this role do?”
The Complete Script to Find Unused IAM Roles
#!/usr/bin/env python3
"""
Find and analyze unused IAM roles across your AWS account
Identifies roles that haven't been used in 90+ days
"""
import boto3
import json
from datetime import datetime, timezone, timedelta
from collections import defaultdict
import argparse
import sys
class UnusedRolesFinder:
def __init__(self, profile=None):
self.profile = profile
self.session = boto3.Session(profile_name=profile) if profile else boto3.Session()
self.iam = self.session.client('iam')
self.unused_roles = []
self.role_analysis = {}
def get_all_roles(self):
"""Get all IAM roles in the account"""
roles = []
paginator = self.iam.get_paginator('list_roles')
for page in paginator.paginate():
roles.extend(page['Roles'])
print(f"Found {len(roles)} total IAM roles")
return roles
def get_role_last_used(self, role_name):
"""Get last used information for a role"""
try:
response = self.iam.get_role(RoleName=role_name)
role = response['Role']
# Check if role has LastUsedDate
if 'RoleLastUsed' in role:
last_used = role['RoleLastUsed'].get('LastUsedDate')
if last_used:
return last_used
return None
except Exception as e:
print(f"Error checking role {role_name}: {str(e)}")
return None
def analyze_role(self, role):
"""Analyze a single role for usage and risk"""
role_name = role['RoleName']
creation_date = role['CreateDate']
# Skip AWS service-linked roles
if role['Path'].startswith('/aws-service-role/'):
return None
# Get last used date
last_used = self.get_role_last_used(role_name)
# Calculate days since last use
if last_used:
days_unused = (datetime.now(timezone.utc) - last_used).days
else:
# Never used - calculate from creation
days_unused = (datetime.now(timezone.utc) - creation_date).days
# Analyze trust policy
trust_policy = role['AssumeRolePolicyDocument']
trust_analysis = self.analyze_trust_policy(trust_policy)
# Get attached policies
attached_policies = self.get_attached_policies(role_name)
# Calculate risk score
risk_score = self.calculate_risk_score(
days_unused,
trust_analysis,
attached_policies
)
analysis = {
'RoleName': role_name,
'Arn': role['Arn'],
'CreatedDate': creation_date.strftime('%Y-%m-%d'),
'LastUsed': last_used.strftime('%Y-%m-%d') if last_used else 'Never',
'DaysUnused': days_unused,
'TrustPolicy': trust_analysis,
'AttachedPolicies': attached_policies,
'RiskScore': risk_score,
'RiskLevel': self.get_risk_level(risk_score),
'Description': role.get('Description', 'No description')
}
return analysis
def analyze_trust_policy(self, trust_policy):
"""Analyze trust policy for risks"""
risks = []
trusted_principals = []
for statement in trust_policy.get('Statement', []):
if statement.get('Effect') != 'Allow':
continue
principal = statement.get('Principal', {})
# Check for risky trust relationships
if isinstance(principal, str) and principal == '*':
risks.append('CRITICAL: Allows anyone to assume')
if isinstance(principal, dict):
# External AWS accounts
if 'AWS' in principal:
aws_principals = principal['AWS']
if isinstance(aws_principals, str):
aws_principals = [aws_principals]
for p in aws_principals:
if ':root' in p and not p.startswith('arn:aws:iam::' + self.get_account_id()):
risks.append('External account trust')
trusted_principals.append(p)
# Federated principals
if 'Federated' in principal:
risks.append('Federated access')
trusted_principals.append(principal['Federated'])
# Service principals
if 'Service' in principal:
trusted_principals.extend(
principal['Service'] if isinstance(principal['Service'], list)
else [principal['Service']]
)
return {
'risks': risks,
'trusted_principals': trusted_principals
}
def get_attached_policies(self, role_name):
"""Get all policies attached to a role"""
policies = []
# Get managed policies
try:
response = self.iam.list_attached_role_policies(RoleName=role_name)
for policy in response['AttachedPolicies']:
policies.append({
'name': policy['PolicyName'],
'arn': policy['PolicyArn'],
'type': 'managed'
})
except:
pass
# Get inline policies
try:
response = self.iam.list_role_policies(RoleName=role_name)
for policy_name in response['PolicyNames']:
policies.append({
'name': policy_name,
'type': 'inline'
})
except:
pass
return policies
def calculate_risk_score(self, days_unused, trust_analysis, policies):
"""Calculate risk score for unused role"""
score = 0
# Base score from days unused
if days_unused > 365:
score += 5
elif days_unused > 180:
score += 3
elif days_unused > 90:
score += 2
else:
score += 1
# Trust policy risks
if 'CRITICAL' in str(trust_analysis.get('risks', [])):
score += 10
elif trust_analysis.get('risks'):
score += len(trust_analysis['risks']) * 2
# Policy risks
admin_policies = ['AdministratorAccess', 'PowerUserAccess']
for policy in policies:
if any(admin in policy['name'] for admin in admin_policies):
score += 5
elif policy['type'] == 'inline':
score += 1
return min(score, 20) # Cap at 20
def get_risk_level(self, score):
"""Convert risk score to level"""
if score >= 15:
return 'CRITICAL'
elif score >= 10:
return 'HIGH'
elif score >= 5:
return 'MEDIUM'
else:
return 'LOW'
def get_account_id(self):
"""Get current AWS account ID"""
try:
return self.session.client('sts').get_caller_identity()['Account']
except:
return 'unknown'
def find_unused_roles(self, days_threshold=90):
"""Find all unused roles"""
print(f"\nSearching for roles unused for {days_threshold}+ days...\n")
roles = self.get_all_roles()
for role in roles:
analysis = self.analyze_role(role)
if analysis and analysis['DaysUnused'] >= days_threshold:
self.unused_roles.append(analysis)
self.role_analysis[analysis['RoleName']] = analysis
# Sort by risk score
self.unused_roles.sort(key=lambda x: x['RiskScore'], reverse=True)
print(f"\nFound {len(self.unused_roles)} unused roles")
def generate_report(self):
"""Generate analysis report"""
if not self.unused_roles:
print("\n✅ No unused roles found!")
return
print("\n" + "="*80)
print("UNUSED IAM ROLES REPORT")
print("="*80)
# Summary statistics
risk_counts = defaultdict(int)
total_policies = 0
for role in self.unused_roles:
risk_counts[role['RiskLevel']] += 1
total_policies += len(role['AttachedPolicies'])
print(f"\nTotal unused roles: {len(self.unused_roles)}")
print(f"Total attached policies: {total_policies}")
print("\nRisk Distribution:")
for level in ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']:
if level in risk_counts:
print(f" {level}: {risk_counts[level]} roles")
# Show critical/high risk roles
critical_roles = [r for r in self.unused_roles if r['RiskLevel'] in ['CRITICAL', 'HIGH']]
if critical_roles:
print(f"\n{'='*80}")
print("⚠️ HIGH RISK UNUSED ROLES (Review immediately)")
print("="*80)
for role in critical_roles[:10]: # Show top 10
print(f"\nRole: {role['RoleName']}")
print(f" Last Used: {role['LastUsed']} ({role['DaysUnused']} days ago)")
print(f" Risk Level: {role['RiskLevel']} (score: {role['RiskScore']})")
print(f" Policies: {len(role['AttachedPolicies'])}")
if role['TrustPolicy']['risks']:
print(f" Trust Risks: {', '.join(role['TrustPolicy']['risks'])}")
# Show admin policies
admin_policies = [p for p in role['AttachedPolicies']
if 'Admin' in p['name'] or 'PowerUser' in p['name']]
if admin_policies:
print(f" ⚠️ Admin Policies: {', '.join([p['name'] for p in admin_policies])}")
def generate_cleanup_script(self, output_file='cleanup_roles.sh'):
"""Generate bash script to delete unused roles"""
if not self.unused_roles:
return
with open(output_file, 'w') as f:
f.write("#!/bin/bash\n")
f.write("# Script to delete unused IAM roles\n")
f.write("# Generated: " + datetime.now().strftime('%Y-%m-%d %H:%M:%S') + "\n")
f.write("# Review carefully before running!\n\n")
f.write("set -e # Exit on error\n\n")
# Group by risk level
for risk_level in ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']:
roles = [r for r in self.unused_roles if r['RiskLevel'] == risk_level]
if roles:
f.write(f"\n# {risk_level} RISK ROLES ({len(roles)} roles)\n")
f.write(f"# Uncomment to delete {risk_level} risk roles\n")
for role in roles:
f.write(f"\n# Role: {role['RoleName']}\n")
f.write(f"# Last used: {role['LastUsed']}\n")
f.write(f"# Policies: {len(role['AttachedPolicies'])}\n")
# Detach policies first
for policy in role['AttachedPolicies']:
if policy['type'] == 'managed':
f.write(f"# aws iam detach-role-policy --role-name '{role['RoleName']}' --policy-arn '{policy['arn']}'\n")
else:
f.write(f"# aws iam delete-role-policy --role-name '{role['RoleName']}' --policy-name '{policy['name']}'\n")
# Delete role
f.write(f"# aws iam delete-role --role-name '{role['RoleName']}'\n")
print(f"\n📄 Cleanup script generated: {output_file}")
print(" Review and uncomment lines before running!")
def export_csv(self, filename='unused_roles.csv'):
"""Export findings to CSV"""
import csv
with open(filename, 'w', newline='') as f:
fieldnames = ['RoleName', 'LastUsed', 'DaysUnused', 'RiskLevel',
'RiskScore', 'PolicyCount', 'TrustRisks', 'Description']
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for role in self.unused_roles:
writer.writerow({
'RoleName': role['RoleName'],
'LastUsed': role['LastUsed'],
'DaysUnused': role['DaysUnused'],
'RiskLevel': role['RiskLevel'],
'RiskScore': role['RiskScore'],
'PolicyCount': len(role['AttachedPolicies']),
'TrustRisks': ', '.join(role['TrustPolicy']['risks']),
'Description': role['Description']
})
print(f"📊 CSV report exported: {filename}")
def main():
parser = argparse.ArgumentParser(description='Find unused IAM roles in AWS')
parser.add_argument('--profile', help='AWS profile to use')
parser.add_argument('--days', type=int, default=90,
help='Days of inactivity threshold (default: 90)')
parser.add_argument('--export-csv', action='store_true',
help='Export results to CSV')
parser.add_argument('--generate-cleanup', action='store_true',
help='Generate cleanup script')
args = parser.parse_args()
# Run analysis
finder = UnusedRolesFinder(profile=args.profile)
finder.find_unused_roles(days_threshold=args.days)
finder.generate_report()
# Export if requested
if args.export_csv:
finder.export_csv()
if args.generate_cleanup:
finder.generate_cleanup_script()
if __name__ == '__main__':
main()
Running the Script
Basic Usage
# Find roles unused for 90+ days
python find_unused_roles.py
# Find roles unused for 180+ days
python find_unused_roles.py --days 180
# Use specific AWS profile
python find_unused_roles.py --profile production
# Export results and generate cleanup script
python find_unused_roles.py --export-csv --generate-cleanup
Sample Output
Found 247 total IAM roles
Searching for roles unused for 90+ days...
Found 67 unused roles
================================================================================
UNUSED IAM ROLES REPORT
================================================================================
Total unused roles: 67
Total attached policies: 143
Risk Distribution:
CRITICAL: 3 roles
HIGH: 12 roles
MEDIUM: 28 roles
LOW: 24 roles
================================================================================
⚠️ HIGH RISK UNUSED ROLES (Review immediately)
================================================================================
Role: old-deployment-role
Last Used: Never (423 days ago)
Risk Level: CRITICAL (score: 18)
Policies: 3
Trust Risks: External account trust
⚠️ Admin Policies: AdministratorAccess
Role: contractor-admin-role
Last Used: 2024-03-15 (198 days ago)
Risk Level: HIGH (score: 13)
Policies: 5
⚠️ Admin Policies: PowerUserAccess
Common Unused Roles You’ll Find
1. The “Temporary” Deployment Role
{
"RoleName": "temp-deploy-role-2023",
"LastUsed": "Never",
"AttachedPolicies": ["AdministratorAccess"],
"Risk": "CRITICAL - Admin access never cleaned up"
}
2. The Ex-Employee Role
{
"RoleName": "john-developer-role",
"LastUsed": "2024-01-15",
"Description": "Role for John (left company)",
"Risk": "HIGH - Terminated employee access"
}
3. The POC That Became Production
{
"RoleName": "poc-lambda-execution",
"CreatedDate": "2022-05-01",
"AttachedPolicies": ["AmazonS3FullAccess", "AmazonDynamoDBFullAccess"],
"Risk": "HIGH - Overly broad permissions"
}
Safe Cleanup Process
1. Verify Before Deleting
# Check CloudTrail for recent usage
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=Username,AttributeValue=old-role-name \
--start-time 2024-01-01 \
--max-items 10
2. Test Impact First
# dry_run_deletion.py
def test_role_deletion(role_name):
"""Simulate role deletion to check dependencies"""
# Check for instance profiles
iam = boto3.client('iam')
instance_profiles = iam.list_instance_profiles_for_role(RoleName=role_name)
if instance_profiles['InstanceProfiles']:
print(f"⚠️ Role used by instance profiles: {instance_profiles}")
return False
# Check for Lambda functions
lambda_client = boto3.client('lambda')
functions = lambda_client.list_functions()
for func in functions['Functions']:
if func.get('Role') and role_name in func['Role']:
print(f"⚠️ Role used by Lambda: {func['FunctionName']}")
return False
return True
3. Gradual Removal
# Step 1: Remove permissions but keep role
aws iam detach-role-policy --role-name old-role --policy-arn arn:aws:iam::aws:policy/AdministratorAccess
# Step 2: Wait 30 days
# Add to calendar
# Step 3: Delete if no issues
aws iam delete-role --role-name old-role
Automating Ongoing Cleanup
GitHub Action for Weekly Reports
# .github/workflows/iam-cleanup.yml
name: Weekly IAM Cleanup Report
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9 AM
workflow_dispatch:
jobs:
iam-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: ${{ secrets.AWS_AUDIT_ROLE }}
aws-region: us-east-1
- name: Run IAM audit
run: |
python find_unused_roles.py --days 90 --export-csv
- name: Upload results
uses: actions/upload-artifact@v3
with:
name: iam-audit-results
path: |
unused_roles.csv
cleanup_roles.sh
- name: Send Slack notification
if: always()
run: |
python send_slack_report.py
Lambda for Continuous Monitoring
import json
import boto3
import os
def lambda_handler(event, context):
"""Lambda function to monitor unused roles"""
finder = UnusedRolesFinder()
finder.find_unused_roles(days_threshold=90)
# Alert on critical findings
critical_roles = [r for r in finder.unused_roles
if r['RiskLevel'] == 'CRITICAL']
if critical_roles:
sns = boto3.client('sns')
message = f"Found {len(critical_roles)} critical unused IAM roles:\n\n"
for role in critical_roles[:5]:
message += f"- {role['RoleName']} (unused {role['DaysUnused']} days)\n"
sns.publish(
TopicArn=os.environ['SNS_TOPIC_ARN'],
Subject='Critical: Unused IAM Roles Found',
Message=message
)
return {
'statusCode': 200,
'body': json.dumps({
'unused_roles': len(finder.unused_roles),
'critical': len(critical_roles)
})
}
Prevention: Stop Creating Unused Roles
1. Use Naming Conventions
# Good role names that are easy to audit
role_naming = {
'pattern': '{environment}-{service}-{function}-role',
'examples': [
'prod-api-lambda-execution-role',
'dev-jenkins-deployment-role',
'staging-ecs-task-role'
]
}
2. Add Metadata Tags
# Tag roles with ownership and purpose
aws iam tag-role \
--role-name my-role \
--tags \
Key=Owner,Value=platform-team \
Key=Purpose,Value=ci-deployment \
Key=ReviewDate,Value=2025-01-01
3. Implement Role Lifecycle
# role_lifecycle.py
def create_role_with_expiry(role_name, trust_policy, expiry_days=90):
"""Create role with automatic expiry reminder"""
iam = boto3.client('iam')
# Create role
iam.create_role(
RoleName=role_name,
AssumeRolePolicyDocument=json.dumps(trust_policy),
Tags=[
{'Key': 'CreatedDate', 'Value': datetime.now().isoformat()},
{'Key': 'ExpiryDate', 'Value': (datetime.now() + timedelta(days=expiry_days)).isoformat()},
{'Key': 'AutoDelete', 'Value': 'true'}
]
)
# Schedule review
schedule_review(role_name, expiry_days)
ROI of Regular IAM Cleanup
Security Improvements:
- Reduced attack surface
- Easier incident investigations
- Clearer permission boundaries
- Better compliance scores
Operational Benefits:
- Faster IAM policy evaluations
- Reduced confusion during debugging
- Cleaner audit reports
- Less time explaining old roles
Cost Savings:
- Fewer security incidents
- Faster audits
- Reduced complexity
- Better team productivity
Conclusion
Unused IAM roles are like old keys under the doormat—forgotten but still dangerous. Regular cleanup isn’t just about security; it’s about maintaining a manageable, understandable AWS environment.
Run this script monthly, clean up the obvious ones immediately, and build habits that prevent role sprawl. Your future self (and your security auditor) will thank you.
Action items:
- Run the script now—it takes 2 minutes
- Delete any “Never used” roles with admin access
- Schedule monthly cleanup reviews
- Tag new roles with purpose and owner
Want automated IAM hygiene without the scripts? Tools like PathShield continuously monitor for unused roles and alert you when high-risk ones appear, before they become security incidents.