☁️ Cloud Security July 13, 2026 · 14 min read

How to Audit AWS IAM: A Practical Engineer Walkthrough

Practical guide to auditing AWS IAM — finding shadow admins, identifying lateral movement paths, scoring permission risk, and what to fix first.

CS
☁️ Cloud Security
CS

The first time a senior security engineer audits a mature AWS organisation’s IAM configuration, the universal reaction is the same: there are more shadow administrators than the org chart shows, more lateral movement paths than the architecture diagram suggests, and more unused permissions than anyone wants to admit. IAM audits are uncomfortable because they expose the gap between what teams think their cloud’s identity model is and what it actually permits. After working through this exercise across many AWS security hardening engagements, a consistent pattern emerges: most teams know their IAM is messy but underestimate by an order of magnitude how messy. This walkthrough is the practical, opinionated, copy-the-commands version — what data to collect, where the dangerous policies actually hide, how to score risk, and what to fix in what order before the next audit or incident lands.

Why most IAM audits miss the dangerous policies

The standard IAM audit looks at managed policies, attached inline policies, and obvious wildcards. It misses the policies that actually matter.

What standard audits catch

The easy findings — and the ones most internal audits stop at:

  • Users with the AdministratorAccess managed policy attached
  • IAM users with no MFA enabled
  • Access keys older than 90 days
  • Root account usage in CloudTrail
  • Inline policies with "Action": "*" and "Resource": "*"

These are useful baseline findings. They catch the obvious problems and they’re table stakes in any compliance program.

What standard audits miss

The findings that actually drive real-world breach risk:

  • Roles that can assume into accounts they shouldn’t be able to reach
  • Service-linked roles with permissions that exceed the service’s documented scope
  • Policy conditions that look restrictive but aren’t (aws:SourceIp without aws:PrincipalAccount, for example)
  • Permission boundaries that gate users but are bypassed by certain SCPs in the parent OU
  • Federated identity providers (SAML, OIDC) with overly broad trust policies
  • Resource-based policies on S3, KMS, Secrets Manager that grant access patterns the IAM-side audit will never see

The dangerous patterns are almost always chained — an attacker doesn’t need one role with admin access; they need a chain of three roles, each looking individually reasonable, that compose into admin access. Finding these chains is the actual work of an IAM audit. For deeper context on AWS attack chains, see our cloud penetration testing across AWS, Azure, and GCP guide.

The audit data you need before starting

An IAM audit without complete data produces conclusions that are wrong in dangerous ways. Collect this first.

From the management account

# Organization structure
aws organizations describe-organization
aws organizations list-accounts
aws organizations list-organizational-units-for-parent --parent-id <root-id>
aws organizations list-policies --filter SERVICE_CONTROL_POLICY

# SCP attachments to OUs and accounts
aws organizations list-targets-for-policy --policy-id <scp-id>

# Delegated administrators for security services
aws organizations list-delegated-administrators

From each member account

# Users, groups, roles
aws iam list-users
aws iam list-groups
aws iam list-roles

# Policies
aws iam list-policies --scope Local --only-attached
aws iam list-policies --scope Local --only-attached false  # find unattached customer-managed

# Credential report (key age, MFA, password)
aws iam generate-credential-report
aws iam get-credential-report

# Access advisor data (last-accessed timestamps)
aws iam generate-service-last-accessed-details --arn arn:aws:iam::<account>:user/<user>

# IAM Access Analyzer findings
aws accessanalyzer list-analyzers
aws accessanalyzer list-findings --analyzer-arn <analyzer-arn>

From CloudTrail

# Recent role assumptions
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
  --max-results 50

# Sensitive identity events
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AttachUserPolicy
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=CreateAccessKey

The data collection takes 30 minutes to a few hours depending on account count. Storing the output as structured JSON makes the analysis steps much faster.

Surface 1 — overly permissive policies

The classic finding. Easy to detect, important to triage by actual impact rather than count.

What to look for

PatternRisk severityNotes
"Action": "*" + "Resource": "*"CriticalFull administrative access
"Action": ["*"] + "Resource": "*"CriticalSame as above, list form
"Action": "iam:*" + "Resource": "*"CriticalCan grant itself anything via PutRolePolicy
"Action": "iam:PassRole" + "Resource": "*"CriticalCan assume any role via service that accepts pass-role
"Action": "*" + scoped resourceHighLess common but appears in service principals
"Action": "sts:AssumeRole" + "Resource": "*"HighLateral movement enabler
"Action": ["s3:*", "ec2:*", ...] no resource scopeHighService-wide unrestricted access
"Action": ["kms:Decrypt", "kms:GenerateDataKey"] on "Resource": "*"HighCan decrypt anything encrypted with any key
"Action": ["secretsmanager:*"] on "Resource": "*"HighCan read all secrets

How to find them

# All policies with broad action scope, fetched and decoded
for policy_arn in $(aws iam list-policies --scope Local --only-attached \
  --query 'Policies[].Arn' --output text); do
  version=$(aws iam get-policy --policy-arn $policy_arn \
    --query 'Policy.DefaultVersionId' --output text)
  doc=$(aws iam get-policy-version --policy-arn $policy_arn \
    --version-id $version --query 'PolicyVersion.Document' --output json)
  echo "$policy_arn"
  echo "$doc" | jq '.Statement[] | select(.Effect == "Allow") |
    select(.Action == "*" or (.Action | type == "array" and any(.[]; . == "*")))'
done

The high-volume false positives in this surface come from AWS-managed policies (AdministratorAccess is meant to be wildcards), so scope your scan to customer-managed and inline policies first.

Surface 2 — shadow admins via trust chain

This is where most audits stop being mechanical. A “shadow admin” is a principal who has effective administrative permissions through a chain of role assumptions, not through a direct AdministratorAccess attachment.

The chain pattern

A common shadow admin pattern:

  1. A developer has sts:AssumeRole on a role called DeveloperRole
  2. DeveloperRole has sts:AssumeRole on DataEngineerRole
  3. DataEngineerRole has iam:PassRole on * and lambda:CreateFunction
  4. The developer creates a Lambda with DataEngineerRole’s trust, escalating to a privileged execution role
  5. That execution role has iam:* because someone added it for “operations”

Each individual role looks reasonable. The chain is administrative access.

How to find them

The mechanical way: enumerate every role’s trust policy, then build a directed graph of “principal X can assume role Y.” Walk the graph from each user to find reachable roles. Score reachable roles by their permission breadth.

The practical way: use IAM Access Analyzer’s external-access finder for cross-account paths, then layer in a tool like iamspy, principalmapper, or cloudsplaining for the in-account analysis.

# Principalmapper workflow
pip install principalmapper
pmapper graph create
pmapper query "preset privesc *"
pmapper query "who can do iam:* with *"
pmapper visualize --filetype svg

The visualize output is often the single most persuasive deliverable from an IAM audit — engineering leaders see the graph and immediately recognise the lateral movement potential.

Surface 3 — unused access (90+ day idle)

Permissions that have not been used recently are permissions the organisation doesn’t need. They are also the lowest-cost finding to remediate because removing them rarely breaks anything.

Sources of “unused”

SignalWhat it indicatesHow to surface
Service Last Accessed shows a service was never calledThe user/role doesn’t need the permissionIAM Access Analyzer “unused access” finder
Action Last Accessed shows a specific action was never calledThe action can be removed from the policySame finder, action-level analysis
User has not logged in in 90+ daysUser account is a candidate for offboardingCredential report password_last_used
Access key has not been used in 90+ daysKey should be rotated or deletedCredential report access_key_1_last_used_date
Role has not been assumed in 90+ daysRole can be deleted or its trust restrictedCloudTrail AssumeRole search

The cleanup pattern

# Find unused roles via Access Analyzer unused access finder
aws accessanalyzer create-analyzer \
  --analyzer-name unused-access-analyzer \
  --type ACCOUNT_UNUSED_ACCESS \
  --configuration unusedAccess={unusedAccessAge=90}

aws accessanalyzer list-findings \
  --analyzer-arn arn:aws:access-analyzer:us-east-1:<account>:analyzer/unused-access-analyzer \
  --filter '{"findingType":{"eq":["UnusedIAMRole"]}}'

The instinct is to delete unused roles aggressively. The safer pattern: tag them for review, communicate to the owning team, give 30 days, then remove. Aggressive cleanup of “unused” roles has broken more than one CI pipeline that ran quarterly.

Surface 4 — service roles with too much

Service-linked roles and service execution roles routinely accumulate permissions over time. The patterns that show up most:

Service roleCommon overprovisioningWhat it should usually be
Lambda execution roles3:* on * for a function that reads one buckets3:GetObject on that one bucket
ECS task rolesecretsmanager:* on * for a service reading two secretssecretsmanager:GetSecretValue on those two secrets
EC2 instance profilessm:* and s3:* because someone copied a tutorialThe specific SSM document actions and S3 bucket needed
Glue service roleiam:PassRole on * because the Glue console asked for itiam:PassRole on the specific service roles Glue needs to pass
CodeBuild service rolecloudformation:* for an org-wide service roleCloudFormation actions scoped to specific stacks
EKS node roles3:* for image pull operationsecr:GetAuthorizationToken, ecr:BatchGetImage

The audit pattern: for each service role, pull its current policy, check IAM Access Advisor for which services it has actually called in the past 90 days, and tighten the policy to those services. This single exercise often reduces the org’s IAM blast radius by 40-60%.

Surface 5 — federation misconfig

SAML and OIDC federation is the most overlooked surface in mid-size organisation IAM audits. The classic patterns:

Overly broad trust policies on federated roles

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::123456789012:saml-provider/Okta" },
  "Action": "sts:AssumeRoleWithSAML",
  "Condition": {
    "StringEquals": {
      "SAML:aud": "https://signin.aws.amazon.com/saml"
    }
  }
}

This trust policy allows any user in the Okta tenant to assume the role, gated only by SAML audience. The fix is to add an attribute-based condition checking group membership:

{
  "Condition": {
    "StringEquals": {
      "SAML:aud": "https://signin.aws.amazon.com/saml",
      "SAML:groups": ["AWSAdmins"]
    }
  }
}

GitHub Actions OIDC misconfigurations

A frequent pattern post-2022 when teams adopted OIDC for CI:

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::<account>:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:my-org/*"
    }
  }
}

This trust allows any workflow in any repo in the my-org GitHub org to assume the role. A compromised contributor on a public-readable repo with workflow access can assume this role. The correct pattern restricts to specific repos and specific branches:

{
  "StringLike": {
    "token.actions.githubusercontent.com:sub": "repo:my-org/prod-deploy:ref:refs/heads/main"
  }
}

Surface 6 — credential exposure

The audit should sweep for credentials that have leaked out of the IAM service:

  • Access keys committed to GitHub (use GitHub’s secret scanning + Amazon’s automatic Quarantine policy attachment for AWS-detected leaks)
  • Credentials in EC2 user data
  • Credentials in Lambda environment variables (use Secrets Manager or Parameter Store instead)
  • Credentials in container images (use IRSA or task roles instead)
  • Credentials in CloudFormation parameters logged to stack events
# Find Lambda functions with credential-shaped environment variables
aws lambda list-functions --query 'Functions[].FunctionName' --output text | \
while read fn; do
  aws lambda get-function-configuration --function-name "$fn" \
    --query 'Environment.Variables' --output json | \
    jq -r 'to_entries[] | select(.value | test("^AKIA|^ASIA"; "i")) | "\($fn): \(.key)"'
done

The fix is rarely “rotate the credential” alone — it’s “switch to a role-based access pattern so this credential doesn’t need to exist.”

Surface 7 — boundary policies and SCPs

Permission boundaries and Service Control Policies are powerful but commonly misconfigured. Common findings:

Permission boundary not actually applied

The pattern: an organisation requires that developer-provisioned roles include a permission boundary, but the requirement is documented in a runbook and not enforced via SCP. New roles slip through without the boundary.

The fix: enforce via SCP:

{
  "Effect": "Deny",
  "Action": ["iam:CreateRole", "iam:PutRolePolicy"],
  "Resource": "*",
  "Condition": {
    "StringNotEquals": {
      "iam:PermissionsBoundary": "arn:aws:iam::<account>:policy/DeveloperBoundary"
    }
  }
}

SCP gaps in OU structure

The pattern: an SCP attached to the parent OU but the workload account moved to a different OU during a reorganisation. The SCP no longer applies. CloudTrail shows actions that the SCP would have blocked happening fine.

The fix: audit SCP attachments and effective policy per account, not per OU.

# Effective SCPs on each account
for account in $(aws organizations list-accounts \
  --query 'Accounts[].Id' --output text); do
  echo "Account: $account"
  aws organizations list-policies-for-target \
    --target-id "$account" --filter SERVICE_CONTROL_POLICY \
    --query 'Policies[].Name' --output table
done

Tooling stack

The right tooling stack for an IAM audit, in approximate order of value:

ToolWhat it doesWhen to use
IAM Access Analyzer (external access)Surface cross-account access pathsAlways — built in, free
IAM Access Analyzer (unused access)Surface unused roles and permissionsAlways — built in, costs per analyzer
principalmapper (pmapper)Build the role-assumption graph, find privesc pathsWhen investigating shadow admins
cloudsplainingStatic analysis of IAM policies for risk patternsWhen auditing the policy library
iamspyZ3-based reasoning over IAM policies for “can principal X do action Y”When verifying access boundaries
ScoutSuiteBroader cloud config audit including IAMWhen the audit scope is broader than IAM
ProwlerCIS-Benchmark-aligned cloud config auditWhen the audit needs benchmark-mapping output
Wiz / Orca / Lacework CIEM moduleContinuous CIEM with attack-path visualisationWhen budget exists for continuous tooling — see Snyk vs Wiz vs Detectify comparison

The native AWS tools cover 60-70% of the value at zero marginal cost. The open-source layer (pmapper, cloudsplaining, ScoutSuite, Prowler) covers another 20%. The remaining 10% — continuous monitoring with attack-path correlation — is where commercial CIEM earns its bill.

What to fix first — severity matrix

After data collection, the question is sequencing. The matrix that works in practice:

Finding typeFix priorityWhy
Root account access keys existDay 1No defensible reason; one of the highest blast-radius issues
MFA disabled on console-access usersDay 1Trivial to fix, drives 80%+ of cred compromise scenarios
Wildcard iam:* on principal with internet-exposed entry pointDay 1-3Direct privesc path
Federated trust policies without group/repo restrictionDay 1-3Identity-provider-side compromise becomes AWS compromise
GitHub OIDC role trusting repo:org/*Day 1-3Any contributor can move laterally to AWS
Shadow admin chains through role assumptionWeek 1-2Requires analysis and coordination to fix safely
Unused IAM users with active access keysWeek 1-2Reduce attack surface; low risk to remediate
Over-permissive service rolesWeek 2-4Highest-volume finding; biggest blast-radius reduction
Missing permission boundaries on developer-provisioned rolesMonth 1-2Requires SCP rollout and developer communication
Stale unused permissions (90+ day idle)Month 2-3Lowest risk, highest noise reduction in continuous monitoring

Sequencing matters because attempting all of these in parallel breaks production. Day-1 fixes are the no-regret remediations. Week-1+ fixes require coordination with engineering teams.

Continuous IAM hygiene after audit

The audit produces a snapshot. The snapshot ages out fast — new roles, new policies, new federation integrations land every week. Sustaining the audit’s improvements requires continuous controls.

The minimum continuous controls

  • SCP enforcing permission boundaries on developer-provisioned roles
  • SCP blocking creation of access keys for users in scope (force role-based access)
  • SCP blocking direct attachment of AdministratorAccess managed policy
  • IAM Access Analyzer (both external-access and unused-access analyzers) enabled in every account, with findings routed to Security Hub and into a ticketing system
  • Automated weekly review of new IAM users, roles, and policies, with anything outside the expected pattern flagged
  • Quarterly access review of human users, covering both AWS console access and federated identity provider memberships

The continuous controls turn the audit from a once-yearly event into an ongoing program. For organisations layering on continuous vulnerability management and managed SOC monitoring, the IAM telemetry plugs into the broader detection stack. For deeper context on AWS-native security tooling that aggregates these findings, see the AWS Security Hub vs GuardDuty vs Inspector comparison.

The argument was never “is IAM important.” Everyone agrees it is. The argument is whether your organisation actually knows what its IAM permits today, and whether your operational controls keep that picture current as the cloud environment evolves. An IAM audit is the forcing function that closes the gap between assumed and actual. The next attacker who lands on an IAM credential won’t read your runbook — they’ll walk the graph. The audit makes sure you’ve walked it first.

#AWS #IAM #Cloud Security #Permissions #Identity

Related articles

Need expert help with Cloud Security?

Our certified security team is ready to assess your environment and recommend the right solutions.

Book a Free Consultation