Most AWS breaches don't start with a zero-day. They start with an IAM role that had * in the Resource field and s3:* in the Action field because someone was in a hurry and it made the error go away. Building a least privilege IAM policy AWS environments can actually enforce is less about intent and more about process: you need a repeatable way to discover what identities actually use, generate tightly scoped policies from that evidence, and verify the result before it ships. This guide walks through that process end to end — from pulling real usage data with AWS Access Advisor, to drafting policies with a policy generator tool, to testing and monitoring drift afterward — so you finish with IAM policy best practices you can apply to every new role, not just the one you're fixing today.
Step 1: Inventory Who Has What Access
Before you can scope anything down, you need a baseline. Least privilege work fails when it's done role-by-role from memory; it works when it starts from a full inventory.
Pull a credential report and an organization-wide view of roles, users, and attached policies:
aws iam generate-credential-report
aws iam get-credential-report --output text --query 'Content' | base64 -d > credential-report.csv
aws iam list-roles --query 'Roles[].{Name:RoleName,Arn:Arn}' --output table
aws iam list-users --query 'Users[].{Name:UserName,Arn:Arn}' --output table
If you're managing multiple accounts, use IAM Access Analyzer's external access findings and AWS Config's iam-policy-no-statements-with-full-access rule to flag policies containing wildcard actions or resources across the whole organization. This gives you a prioritized list — start with roles that have broad permissions and high usage (production compute, CI/CD deploy roles) rather than the long tail of rarely-used service roles.
Step 2: Pull Real Usage Data with AWS Access Advisor
This is the step teams skip, and it's the one that actually makes least privilege achievable instead of theoretical. Guessing what a role "should" need produces broken applications. Looking at what it has actually called in the last 90+ days produces a policy that works.
For any role or user, the access advisor tab in the IAM console (Access → "Access Advisor") shows every service the identity has touched and the last-accessed timestamp. You can pull the same data via CLI for automation:
# Generate the service last-accessed report
aws iam generate-service-last-accessed-details --arn arn:aws:iam::123456789012:role/deploy-role
# Retrieve results using the JobId returned above
aws iam get-service-last-accessed-details --job-id <job-id>
For action-level granularity (not just service-level), request the granular report:
aws iam generate-service-last-accessed-details \
--arn arn:aws:iam::123456789012:role/deploy-role \
--granularity ACTION_LEVEL
aws iam get-service-last-accessed-details-with-entities \
--job-id <job-id> \
--service-namespace s3
Anything that shows "Not accessed in tracking period" is a candidate for removal. Give yourself at least one full business cycle (30-90 days depending on how often batch jobs or monthly reports run) before you trust the data — cutting a permission that's used quarterly because it looks unused in a 14-day window is a common self-inflicted outage.
Step 3: Draft the Least Privilege IAM Policy AWS Roles Actually Need
With usage data in hand, draft a policy that grants only the actions actually observed, scoped to specific resources rather than *. A hand-written example for an S3-writing Lambda function:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowScopedS3Write",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject"
],
"Resource": "arn:aws:s3:::app-uploads-prod/*"
},
{
"Sid": "AllowScopedLogging",
"Effect": "Allow",
"Action": "logs:PutLogEvents",
"Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/upload-handler:*"
}
]
}
Writing this by hand for every role doesn't scale, which is why most teams reach for a policy generator tool at this stage. AWS's own IAM Access Analyzer can generate a policy automatically from CloudTrail activity for a given role:
aws accessanalyzer start-policy-generation \
--policy-generation-details '{"principalArn":"arn:aws:iam::123456789012:role/deploy-role"}' \
--cloud-trail-details '{"trails":[{"cloudTrailArn":"arn:aws:cloudtrail:us-east-1:123456789012:trail/org-trail","regions":["us-east-1"]}],"accessRole":"arn:aws:iam::123456789012:role/AccessAnalyzerServiceRole","startTime":"2026-04-01T00:00:00Z"}'
aws accessanalyzer get-generated-policy --job-id <job-id>
Whichever generator you use — AWS-native or a third-party IAM policy generator tool — treat the output as a strong first draft, not a final answer. Automated generation is excellent at capturing what happened but blind to what should be allowed for planned but not-yet-exercised functionality (a new endpoint that hasn't shipped yet, a disaster-recovery path that only runs during failover). Review the generated statements against your architecture before merging.
Step 4: Apply Conditions and Resource Constraints
Actions and resources are only part of least privilege. Conditions narrow a policy further based on context — source IP, MFA presence, tags, encryption requirements — which matters a lot for roles that are correctly scoped on actions but still too permissive on when and how they can be used.
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::app-uploads-prod/*",
"Condition": {
"Bool": { "aws:SecureTransport": "true" },
"StringEquals": { "s3:ExistingObjectTag/Environment": "production" }
}
}
For human identities, require MFA for anything sensitive:
{
"Effect": "Deny",
"NotAction": ["iam:CreateVirtualMFADevice", "iam:EnableMFADevice", "sts:GetSessionToken"],
"Resource": "*",
"Condition": {
"BoolIfExists": { "aws:MultiFactorAuthPresent": "false" }
}
}
Attribute-based access control (ABAC) using tags is worth adopting here too — a single policy that grants access only when a Project tag on the principal matches the Project tag on the resource scales far better than maintaining a hand-rolled policy per project.
Step 5: Test Before You Deploy
Never attach a tightened policy directly to a production role and hope. Use the IAM policy simulator to test specific API calls against the draft policy before it goes live:
aws iam simulate-custom-policy \
--policy-input-list file://draft-policy.json \
--action-names s3:PutObject s3:DeleteObject \
--resource-arns arn:aws:s3:::app-uploads-prod/2026/report.csv
For a safer rollout, attach the new policy in parallel with the old one, switch the application traffic, and monitor CloudTrail and Access Advisor for a full cycle. Only detach the legacy policy once you've confirmed zero denied-call errors tied to the change. For anything customer-facing, stage this through a non-production account first.
Step 6: Automate and Monitor for Drift
Least privilege isn't a one-time project — permissions creep back in as engineers add "just one more action" under deadline pressure. Bake enforcement into your pipeline:
- Run
aws accessanalyzer validate-policyin CI against every IAM policy change to catch overly permissive statements, unused actions, and security warnings before merge. - Set a recurring job (weekly or monthly) that re-pulls Access Advisor data for all roles and flags newly-unused permissions.
- Use Service Control Policies (SCPs) at the organization level as a backstop — a guardrail that blocks
iam:*Policy*actions granting*:*regardless of what an individual account's IAM admin does.
aws accessanalyzer validate-policy \
--policy-document file://draft-policy.json \
--policy-type IDENTITY_POLICY
Troubleshooting and Verification
"AccessDenied" errors after tightening a policy. Check CloudTrail's errorCode: AccessDenied events for the exact action and resource the caller attempted — Access Advisor only shows what succeeded historically, so a legitimate but rare code path can get missed. Add the specific action back with the narrowest resource scope that satisfies it, rather than reverting to the old broad policy.
Access Advisor shows "No data available." This usually means the role is newer than the tracking period, or it's only ever assumed via a service that doesn't route through IAM's tracked API surface (some console-only or region-specific calls have historically had gaps). Cross-check against CloudTrail event history for the same time window before concluding the permission is unused.
Policy simulator says "allowed" but the real call still fails. The simulator doesn't evaluate resource-based policies (like an S3 bucket policy or KMS key policy) attached on the other side of the call. Check both the identity-based policy and the resource-based policy — a least privilege IAM policy AWS accepts on the identity side can still be blocked, or unintentionally widened, by the resource policy.
Generated policy is missing permissions for infrequent batch jobs. Extend the CloudTrail lookback window used for policy generation, or add explicit statements for known scheduled tasks (month-end reports, annual key rotation scripts) that won't show up in a 90-day sample.
How Safeguard Helps
Manually stitching together Access Advisor exports, CloudTrail logs, and policy generator output across dozens of accounts is exactly the kind of drift-prone, spreadsheet-driven process that lets over-permissioned roles survive for years. Safeguard continuously maps the permissions your AWS identities actually use against what their policies grant, surfacing unused actions, wildcard statements, and cross-account exposure as part of your normal software supply chain security posture — not as a one-off audit.
Because Safeguard treats IAM configuration as another artifact in your supply chain, tightening a role's policy goes through the same provenance and review checks you already apply to code and dependencies: changes are tracked, scoped, and verifiable, so a least privilege IAM policy AWS teams roll out today doesn't quietly drift back into an over-permissioned one six months from now. If you're ready to move from periodic IAM cleanups to continuous enforcement, Safeguard can plug into your existing AWS Organizations setup and start surfacing findings without requiring a rewrite of how your teams manage access today.