The Policy Everyone Writes First
You're building something, you hit an AccessDenied error, and you're three deploys behind schedule. So you attach AdministratorAccess to unblock yourself. "I'll scope it down later."
Later never comes.
Six months on, that role - the one your CI pipeline assumes, or your Lambda runs as, or your EC2 instance carries - still has full account access. Nobody remembers why. Nobody wants to be the one who breaks something by removing a permission.
That's how a single compromised credential becomes a full account compromise.
What Least Privilege Actually Means
Least privilege isn't "restrict things eventually." It's a specific target: a principal can perform exactly the actions it needs, on exactly the resources it needs, and nothing else.
1. Identify what the workload actually calls (not what it might call someday)
↓
2. Write a policy naming those specific actions
↓
3. Scope the Resource element to specific ARNs, not "*"
↓
4. Add Conditions where actions alone aren't specific enough
↓
5. Test against real usage
↓
6. Re-run periodically - permissions drift, workloads change
Every wildcard you leave in is a permission you haven't justified yet.
Bad Policy vs Scoped Policy
What most people ship
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "dynamodb:*",
"Resource": "*"
}
]
}
Problems:
- Grants delete, and bucket policy changes, on every bucket in the account - not just the one this app touches
- Grants full DynamoDB table management, not just the reads/writes the app needs
- If this role is compromised, the attacker has access to unrelated data in unrelated buckets and tables
What a scoped policy looks like
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::app-uploads-prod/*"
},
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/app-sessions"
}
]
}
What changed:
- Named actions only - no delete, no bucket policy changes, no table creation
- Resource pinned to one bucket path and one table ARN
- A compromised credential here can read and write session data. It can't touch any other bucket, table, or account resource.
Finding Out What a Workload Actually Needs
Guessing permissions is how you end up back at wildcards. Use what AWS already logged.
IAM Access Analyzer: policy generation from CloudTrail
Access Analyzer can generate a policy from a role's actual CloudTrail activity over a chosen window:
aws accessanalyzer start-policy-generation \
--policy-generation-details principalArn=arn:aws:iam::123456789012:role/app-role \
--cloud-trail-details '{
"trails": [{"cloudTrailArn": "arn:aws:cloudtrail:us-east-1:123456789012:trail/main-trail", "regions": ["us-east-1"]}],
"startTime": "2026-06-01T00:00:00Z",
"endTime": "2026-07-01T00:00:00Z"
}'
Run the workload for a real usage cycle - including its slow paths, cron jobs, and error-handling branches - before generating. A policy built from three days of happy-path traffic will break the first time a scheduled job runs.
Access Advisor: what's unused
For roles that already exist, IAM's Access Advisor shows which services (and, with granular tracking, which actions) haven't been used in the last 365 days. Anything untouched for that long is a candidate for removal - not a candidate for "leave it in case."
Conditions: Scoping Beyond Actions and Resources
Sometimes the action and resource aren't specific enough on their own. Conditions add context.
| Condition key | Restricts to |
|---|---|
aws:SourceIp |
Requests from a specific IP range |
aws:PrincipalTag/team |
Principals tagged with a specific value |
aws:RequestedRegion |
A specific AWS region, blocking others |
s3:x-amz-server-side-encryption |
Uploads that specify encryption |
aws:MultiFactorAuthPresent |
Requests where MFA was used |
{
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::app-uploads-prod/*",
"Condition": {
"StringEquals": { "s3:x-amz-server-side-encryption": "aws:kms" }
}
}
This doesn't just say "this role can upload." It says "this role can upload, and only if the object is encrypted with KMS." An upload without that header is denied, even from a principal with the action allowed.
Roles, Permission Boundaries, and SCPs - Different Layers
These get conflated constantly. They're not the same tool.
| Layer | Scope | Purpose |
|---|---|---|
| Identity policy | One role or user | Grants specific permissions |
| Permission boundary | One role or user | Caps the maximum a role can ever have, even if granted more |
| Service Control Policy (SCP) | Entire AWS Organization or OU | Caps what any identity in the account can do, account-wide |
A permission boundary is useful when a role needs to create other roles (common in CI/CD or platform-team automation) - it stops that role from ever creating something more powerful than itself, even by accident.
An SCP is useful when you want a guardrail no individual IAM policy can override - like blocking iam:CreateAccessKey account-wide so nobody can quietly reintroduce long-lived credentials.
What I Actually Do
I start every new role with zero permissions and a policy that intentionally fails, then add exactly what the first real error tells me is missing. It's slower than starting from AdministratorAccess and trimming down, but trimming down never actually happens - I've never once gone back and removed a permission from a policy that already worked.
I also don't share roles across workloads that don't share a trust boundary. One role per service, even when it means more IAM resources to manage. The alternative is a shared role where the "actual" set of required permissions is the union of everything every service that ever used it needed, which is just a wildcard with more steps.
Common Mistakes
Mistake #1: Scoping Action but leaving Resource: "*".
- Why it's wrong: Naming specific actions feels like least privilege, but a scoped action on every resource in the account still has enormous blast radius.
- What to do: Pin the resource ARN. If you can't name it, you probably don't understand what the workload touches yet.
Mistake #2: Treating permission boundaries as a substitute for identity policies.
- Why it's wrong: A boundary only caps the maximum. Without a tight identity policy underneath it, the role still has everything the boundary allows.
- What to do: Use both - a tight identity policy for what's actually needed, a boundary as a backstop against future over-grants.
Mistake #3: Auditing IAM once, at setup, and never again.
- Why it's wrong: Permissions accumulate. Someone adds
s3:*"temporarily" to debug something and it never gets reverted. Six months later nobody remembers why it's there. - What to do: Review Access Advisor on production roles on a schedule - quarterly, at minimum - and remove anything unused.
TL;DR
Problem: Wildcard IAM policies (Action: *, Resource: *) get shipped under deadline pressure and never get scoped down.
Solution: Generate policies from actual CloudTrail usage with IAM Access Analyzer, pin resources to specific ARNs, add conditions where actions alone aren't specific enough, and layer permission boundaries or SCPs as backstops.
Result: A compromised credential can only do what that one workload actually needed to do - not everything the account allows.
If a role in your account has AdministratorAccess and you can't say why in one sentence, that's the fifteen minutes to spend today.
Tags: AWS · IAM · least privilege · security · Access Analyzer · CloudTrail · permission boundaries · SCP