Write IAM Policies That Aren't Just Star Star
01 Jan 2026"Action": "*" on a policy is not a starting point you’ll fix later. It’s a starting point you’ll forget about until a security review or an incident finds it. Here’s how to narrow one down without breaking the app it protects.
Don’t write the policy from a blank page
Guessing which permissions an app actually needs and writing a policy by hand is how you end up either too broad (defeats the purpose) or too narrow (breaks production at 2am). Instead, let AWS tell you what’s actually being used.
1. Turn on CloudTrail if it isn’t already
You need a record of every API call the role or user actually makes. If CloudTrail’s been running for a while, you already have this data.
2. Generate a policy from actual usage
IAM Access Analyzer’s policy generation feature reads CloudTrail logs for a role over a time window and produces a policy scoped to exactly the actions it actually called:
aws accessanalyzer start-policy-generation \
--policy-generation-details principalArn=arn:aws:iam::123456789012:role/my-app-role \
--cloud-trail-details '{"trails":[{"cloudTrailArn":"arn:aws:cloudtrail:us-east-1:123456789012:trail/my-trail"}],"startTime":"2026-08-01T00:00:00Z","endTime":"2026-09-01T00:00:00Z"}'
This gives you a real policy based on real behavior, not a guess. Run it over a window long enough to cover your app’s actual usage patterns — a month is reasonable for most apps, longer if you have things that only run monthly (backups, batch jobs) that you don’t want to miss.
3. Don’t just paste the generated policy in and walk away
The generated policy reflects what happened during the window, not what should be allowed. Review it for two things:
- Missing actions for code paths that didn’t run during the window — a monthly cron job, an admin-only feature, an error-recovery path. If it exists in code but didn’t fire in your sample window, it won’t be in the generated policy, and you’ll find out it’s missing exactly when that code path runs for the first time in production.
- Resource scoping. The generated policy often scopes by action correctly but leaves
Resource: "*". Narrow it to actual ARNs where you can:
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-app-bucket/uploads/*"
}
Not arn:aws:s3:::*. Scope to the specific bucket and prefix the app actually touches.
4. Roll it out safely, not in one cutover
Don’t replace the production policy in place. Attach the new, narrower policy to a test role or a canary instance first, and run your actual test suite and a normal traffic sample against it. IAM’s policy simulator (aws iam simulate-principal-policy) lets you check specific actions against a candidate policy without deploying anything:
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/my-app-role \
--action-names s3:DeleteObject \
--resource-arns arn:aws:s3:::my-app-bucket/uploads/file.png
Only cut production over once you’ve confirmed nothing that should work has been denied.
5. Use Deny for the things that should never happen, regardless of Allow
Least privilege isn’t just narrowing Allow — an explicit Deny on a genuinely dangerous action (deleting the production database, modifying IAM itself) is a backstop that holds even if a future Allow statement gets added carelessly. Deny always wins over Allow in IAM evaluation, which makes it the right place to put your hard lines.
Keep it from drifting back
Access Analyzer also runs continuously and flags unused permissions in existing policies going forward, not just at generation time — turn that on and revisit flagged findings on a schedule (quarterly is reasonable), because the whole point of this exercise is undone if nobody looks again after this week.