DEV Community

IAM Security Audit

This is Part 4 of a 4-part series I wrote while prepping to renew my AWS Security Specialty certification (SCS-C03). Parts 1 through 3 cover IAM foundations, STS and federation, and advanced patterns like ABAC, SCPs, and permission boundaries. You can find them on my blog at tolubanji.com.

I ran IAM Access Analyzer on an account I'd been managing for quite a while now and found 14 resources with external access. Three S3 buckets, two Lambda functions, and nine IAM roles that could be assumed by accounts outside our organization. I knew about one of the buckets. The rest were news to me.

Traced it back to a CloudFormation template with an overly permissive trust policy that had been copied across 18 months of microservice deployments. Nobody noticed because nothing broke.

If Parts 1 through 3 covered how IAM works, this is about what goes wrong and how to find it.

IAM Security Audit Overview

Common IAM Misconfigurations

These are the ones I keep seeing across accounts. None of them are exotic. They're all mundane, easy to create, and easy to miss.

Overly Permissive Policies

The classics:

{
  "Effect": "Allow",
  "Action": "*",
  "Resource": "*"
}
Enter fullscreen mode Exit fullscreen mode

You'd think nobody does this in production. They do. Sometimes it starts as a "temporary" policy during development. Sometimes it's an AWS managed policy that's broader than the name suggests. ReadOnlyAccess sounds safe until you see it includes secretsmanager:GetSecretValue. Access Analyzer's policy validation (covered below) catches these, and the credential report will show you which identities have them attached.

Overly Permissive Trust Policies

Worse than a bad permission policy because this one controls who gets in the door:

{
  "Principal": {"AWS": "*"},
  "Action": "sts:AssumeRole"
}
Enter fullscreen mode Exit fullscreen mode

That * means any AWS account in the world can assume your role. I've found this in production more than once. Usually it's a role that was set up for cross-account access and someone used * as a placeholder. The placeholder became permanent.

The slightly less obvious version:

{
  "Principal": {"AWS": "arn:aws:iam::123456789012:root"}
}
Enter fullscreen mode Exit fullscreen mode

This allows any principal in that account. If that's a partner account, every user and role they have can assume your role. Scope it to specific roles instead.

Unused Credentials

Long-lived access keys that nobody's using are the credentials most likely to be compromised. They sit in old repos, forgotten config files, and former contractors' laptops.

# Generate and download the credential report
aws iam generate-credential-report
aws iam get-credential-report --query 'Content' --output text | \
  base64 -d > cred-report.csv
Enter fullscreen mode Exit fullscreen mode

The credential report shows you everything: password last used, access key last used, MFA status, key age. I run this monthly and look for access keys older than 90 days with no recent usage. If they haven't been used, they probably shouldn't exist.

Missing MFA

Console access without MFA is an open invitation. Check for it:

# Find users with console access but no MFA
aws iam get-credential-report --query 'Content' --output text | \
  base64 -d | awk -F',' '$4 == "true" && $8 == "false" {print $1}'
Enter fullscreen mode Exit fullscreen mode

That one-liner pulls users who have a password (console access) but no MFA device. Every name on that list is a risk.

Inline Policies

I mentioned inline policies in Part 1. They're embedded directly in a user, group, or role. Hard to audit, don't show up in policy listings, and nobody remembers they exist.

# Find inline policies on all users
aws iam list-users --query 'Users[].UserName' --output text | \
  xargs -I {} sh -c \
    'policies=$(aws iam list-user-policies --user-name {} --query "PolicyNames[]" --output text); \
     [ -n "$policies" ] && echo "User {}: $policies"'
Enter fullscreen mode Exit fullscreen mode

If this returns anything, move those policies to customer managed policies where they can be versioned and audited.

Privilege Escalation Paths

An attacker who gets limited IAM access will immediately look for ways to escalate. These are the paths they'll try.

The Dangerous Permissions

Not all IAM permissions are equal. Some let you escalate to admin without anyone noticing:

Permission What an Attacker Does
iam:CreatePolicyVersion Updates an existing policy to grant admin, sets it as default
iam:AttachUserPolicy / iam:AttachRolePolicy Attaches AdministratorAccess to themselves or a role they can assume
iam:PutUserPolicy / iam:PutRolePolicy Creates an inline admin policy on their identity
iam:PassRole + lambda:CreateFunction Creates a Lambda function with an admin role, invokes it
iam:PassRole + ec2:RunInstances Launches an EC2 instance with an admin role, SSMs into it
iam:UpdateAssumeRolePolicy Changes a role's trust policy to allow themselves to assume it

The iam:PassRole combinations are the ones people miss. By itself, PassRole doesn't look dangerous. Combined with a service that executes code (Lambda, EC2, ECS, Glue), it's a full escalation path.

Lambda + PassRole Escalation

I see this one more than any other:

# Attacker has: lambda:CreateFunction, lambda:InvokeFunction, iam:PassRole

# Step 1: Create function with a powerful role
aws lambda create-function \
  --function-name escalate \
  --role arn:aws:iam::123456789012:role/AdminRole \
  --handler index.handler \
  --runtime python3.12 \
  --zip-file fileb://payload.zip

# Step 2: Invoke it. The code runs as AdminRole.
aws lambda invoke --function-name escalate output.json
Enter fullscreen mode Exit fullscreen mode

The Lambda function's code can do anything AdminRole can do. Create new access keys, exfiltrate data, modify other roles. The attacker never directly had admin access, but they got it through the service.

CreatePolicyVersion Escalation

This one is subtle. If you can create a new version of a policy that's attached to your own identity:

aws iam create-policy-version \
  --policy-arn arn:aws:iam::123456789012:policy/MyPolicy \
  --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}' \
  --set-as-default
Enter fullscreen mode Exit fullscreen mode

One command. The policy now grants admin. And because it's the same policy ARN, it might not trigger alerts that look for new policy attachments.

Preventing Escalation

Permission boundaries from Part 3. They set a ceiling that can't be exceeded regardless of what policies get attached, and they're the best tool you have here.

Beyond that:

  • Deny iam:CreatePolicyVersion and iam:SetDefaultPolicyVersion for non-admin roles
  • Scope iam:PassRole to specific role ARNs and specific services (iam:PassedToService condition)
  • Monitor IAM changes through CloudTrail (covered below)
  • Run regular access reviews. Quarterly at minimum.

IAM Access Analyzer

Access Analyzer is the tool I wish existed when I first started managing AWS accounts. It does four things: finds resources shared externally, validates policies, generates least-privilege policies from activity, and checks for unused access.

Setting It Up

# Create an account-level analyzer
aws accessanalyzer create-analyzer \
  --analyzer-name my-analyzer \
  --type ACCOUNT

# Or organization-level (sees all accounts)
aws accessanalyzer create-analyzer \
  --analyzer-name org-analyzer \
  --type ORGANIZATION
Enter fullscreen mode Exit fullscreen mode

Use ORGANIZATION type if you have AWS Organizations set up. It detects sharing between your accounts and external accounts, not just within one account.

Reviewing Findings

aws accessanalyzer list-findings \
  --analyzer-arn arn:aws:access-analyzer:us-east-1:123456789012:analyzer/my-analyzer \
  --query 'findings[].{Resource:resource,Type:resourceType,Access:principal}'
Enter fullscreen mode Exit fullscreen mode

Each finding tells you what resource is shared, with whom, and through which policy. You can archive findings that are intentional (like a bucket shared with a partner) so they don't clutter future reviews.

Policy Validation

I run this on every policy before it touches an environment:

aws accessanalyzer validate-policy \
  --policy-document file://policy.json \
  --policy-type IDENTITY_POLICY
Enter fullscreen mode Exit fullscreen mode

It catches things like unused actions, overly permissive resources, and missing conditions. Not perfect, but it catches the obvious mistakes before they reach production.

Policy Generation from CloudTrail

Access Analyzer can also analyze CloudTrail logs and generate a least-privilege policy based on what a role has been doing:

# Start policy generation (analyzes last 90 days of CloudTrail)
aws accessanalyzer start-policy-generation \
  --policy-generation-details '{
    "principalArn": "arn:aws:iam::123456789012:role/MyRole"
  }'

# Check the result
aws accessanalyzer get-generated-policy --job-id JOB_ID
Enter fullscreen mode Exit fullscreen mode

I use this when inheriting roles from other teams. Instead of guessing what permissions they need, I let Access Analyzer tell me what they've been using. Then I create a policy based on that. Not the permissions they were granted, but the ones they used.

Access Analyzer vs Access Advisor

The exam tests the difference. Access Analyzer finds external sharing and validates policies. Access Advisor (the "Last Accessed" data in the IAM console) shows which services a role has used and when. Different tools, different purposes. Analyzer is about security posture. Advisor is about right-sizing permissions.

Auditing IAM with CloudTrail

CloudTrail records every API call in your account. Most of those events are noise. Here's what's worth watching.

Events That Should Trigger Alerts

The events I care about most: CreateUser, CreateAccessKey, AttachUserPolicy, AttachRolePolicy, CreatePolicyVersion, UpdateAssumeRolePolicy, PutUserPolicy, PutRolePolicy. Any of these happening outside a change management window is worth investigating.

ConsoleLogin failures are also worth watching. A burst of failed logins followed by a success is a classic brute-force pattern. AssumeRole from unexpected source accounts is another red flag.

Querying CloudTrail

# All IAM changes in the last 7 days
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventSource,AttributeValue=iam.amazonaws.com \
  --start-time $(date -v-7d '+%Y-%m-%d') \
  --query 'Events[].{Time:EventTime,Event:EventName,User:Username}'
Enter fullscreen mode Exit fullscreen mode

EventBridge Rule for IAM Alerts

An EventBridge rule watching for IAM privilege changes is one of those things you configure once and it pays for itself every week:

{
  "source": ["aws.iam"],
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventName": [
      "AttachUserPolicy",
      "AttachRolePolicy",
      "CreatePolicyVersion",
      "PutUserPolicy",
      "PutRolePolicy",
      "CreateAccessKey",
      "UpdateAssumeRolePolicy"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Route this to SNS and you get an email every time someone modifies IAM permissions. Noisy at first. Essential once you tune it.

Credential Reports and Last Accessed

The Credential Report

I mentioned this above for finding missing MFA. The full report covers every user in the account: password status, access key age, last login, MFA status. It's the single best snapshot of your IAM hygiene. Same commands from above to generate it.

What I look for every month: users with no MFA, access keys older than 90 days, access keys that have never been used, users who haven't logged in for 90+ days. Each of these is either a cleanup task or a conversation with someone about why the credential still exists.

Service Last Accessed

Shows you which AWS services a role has touched and when:

JOB_ID=$(aws iam generate-service-last-accessed-details \
  --arn arn:aws:iam::123456789012:role/MyRole \
  --query 'JobId' --output text)

aws iam get-service-last-accessed-details --job-id $JOB_ID \
  --query 'ServicesLastAccessed[?LastAuthenticated!=`null`].{Service:ServiceName,LastUsed:LastAuthenticated}'
Enter fullscreen mode Exit fullscreen mode

If a role has s3:*, dynamodb:*, sqs:*, and sns:* but has only used S3 and DynamoDB in the last 6 months, remove SQS and SNS. This is how you get to least privilege without guessing.

Responding to Compromised Credentials

When you find compromised credentials, speed matters. The attacker might be active while you're figuring out what happened. Here's the sequence I follow.

Compromised Credentials Response Flow

First, attach an inline deny-all policy to the compromised user. An explicit deny overrides everything (remember the evaluation logic from Part 1). This is faster than trying to detach all their policies one by one because you might miss one. The deny-all takes effect immediately and locks them out regardless of what other policies they have.

Second, disable all access keys on that identity. Disable, not delete. You'll need the key IDs later for the investigation. Disabling stops the keys from working while preserving the evidence.

Third, remove console access by deleting their login profile. If the attacker has the password, this kills their ability to use the AWS console.

Fourth, investigate through CloudTrail. Pull all events for that username going back at least 30 days. You're looking for three categories of activity: data access (S3 GetObject, DynamoDB Scan, Secrets Manager GetSecretValue), persistence attempts (CreateUser, CreateAccessKey, UpdateAssumeRolePolicy), and lateral movement (AssumeRole into other accounts or more privileged roles). The persistence attempts are the most important to find because they mean the attacker may still have access through a different path even after you've locked down the original credentials.

Exam Notes

Know the privilege escalation paths, especially iam:PassRole combined with service actions. The exam gives scenarios where a user has seemingly limited permissions and asks what they can do.

Access Analyzer vs Access Advisor: Analyzer finds external sharing and validates policies. Advisor shows service last accessed data. Different tools.

Access Analyzer can generate policies from CloudTrail activity. Know this for questions about achieving least privilege.

Credential reports cover every IAM user in the account. They show password age, key age, MFA status, and last usage.

CloudTrail records all IAM API calls. Know the event names for privilege changes.

For compromised credentials: deny first, then disable keys, then investigate. Order matters because the attacker might be active while you're responding.

Top comments (0)