· 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.

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:

  1. Run the script now—it takes 2 minutes
  2. Delete any “Never used” roles with admin access
  3. Schedule monthly cleanup reviews
  4. 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.

Back to Blog

Related Posts

View All Posts »