Cloud IAM Least-Privilege Without Breaking Everything
Least privilege fails when it's a one-time audit that locks things down until something breaks, then gets reverted. The iterative, log-driven approach that tightens permissions safely, and the policies we stopped writing by hand.
Key takeaways
- Reach least privilege by measuring, not guessing.
- Generate policies from up to 90 days of CloudTrail activity with IAM Access Analyzer, or use GCP role recommendations, then tighten in stages with AccessDenied alerting.
- Cap the worst case with permissions boundaries and SCPs, and replace static keys with short-lived federated credentials.
On this page
Get to least privilege by generating policies from observed activity instead of writing them from imagination. On AWS, IAM Access Analyzer builds a policy from up to 90 days of CloudTrail history; GCP role recommendations use a 90-day window too. Tighten in stages with AccessDenied alerting, cap blast radius with permissions boundaries and SCPs, and kill static keys.
Verified against
AWS IAM Access Analyzer policy generation, IAM permissions boundaries and AWS Organizations SCP docs, plus Google Cloud role recommendations docs. Checked October 2026.
Everyone agrees on least privilege in principle. In practice it tends to go one of two ways: permissions sprawl to *:* because that's what makes the deploy work at 5pm on a Friday, or someone does a heroic lockdown that breaks a batch job nobody remembered, gets paged, and reverts the whole thing. Neither gets you there. What works is an iterative, evidence-driven process rather than a one-shot audit.
Why the big-bang lockdown fails
A from-scratch "minimal" policy is guesswork. You can't enumerate every action a service legitimately needs by reading the code: there are calls in error paths, in monthly jobs, in dependencies' SDKs you've never inspected. So a hand-written minimal policy is always missing something, and the thing it's missing surfaces as a production failure days later, often in a code path with no good error handling. After two of those, the team's lesson is "least privilege causes outages" and they stop trying.
The approach: observe, then tighten
Get the data instead of guessing. Every major cloud logs which identity called which API. Mine those logs to learn what each role actually uses, then write the policy to match observed behavior plus a margin.
AWS: IAM Access Analyzer generates a policy template from CloudTrail history. You give it the principal, a trail, a service role it can use to read the logs, and a window of up to 90 days:
# cloudtrail-details.json holds trails, accessRole, startTime (see the CLI reference)
aws accessanalyzer start-policy-generation \
--policy-generation-details '{"principalArn":"arn:aws:iam::123456789012:role/my-service"}' \
--cloud-trail-details file://cloudtrail-details.json
{ "jobId": "a1b2c3d4-5678-90ab-cdef-EXAMPLE11111" }
aws accessanalyzer get-generated-policy --job-id a1b2c3d4-5678-90ab-cdef-EXAMPLE11111
The output is a template, not a finished policy. Resource-level actions come back with ARN placeholders you fill in, and some services only get service-level detail that you complete by hand.
GCP: role recommendations flag permissions granted but unused and suggest a tighter role. The maximum observation period is 90 days; for project-level recommendations you can shorten the wait to 30 or 60 days, at some cost to accuracy. Azure: Entra access reviews and PIM usage data fill the same role.
The shift is from "what might this need?" (unknowable, so you over-grant) to "what has this used in 90 days?" (measured, so you can scope precisely).
What the generated policy will miss
Policy generation has documented blind spots, and every one of them is a future outage if you don't know about it:
- Data events are not covered. Access Analyzer does not identify action-level activity for data events such as S3 object reads and writes. A role that lives on
s3:GetObjectgets a policy that doesn't say so. Add those actions from code review, and scope them to the bucket (our S3 buckets and permissions tutorial covers the bucket-policy side). iam:PassRoleis invisible. CloudTrail doesn't track it, so it never appears in a generated policy. Anything that launches EC2 instances, Lambda functions or ECS tasks with a role will break.- Control Tower trails don't work. Policy generation can't read the trails AWS Control Tower creates, so organizations on Control Tower need a separate trail.
- 90 days is the ceiling. Anything that runs annually (certificate renewals, year-end jobs) won't show up.
Tighten in stages, with a safety net
Don't go from broad to minimal in one step. Stage it:
- Confirm "unused" over a long enough window. 90 days catches monthly and quarterly jobs; 30 days does not. The batch job that runs on the 1st will bite you.
- Tighten in a non-prod account, run the full workload including the rare paths (DR drills, batch, backfills), and watch for
AccessDenied. - Roll to prod with alerting on authorization failures, so a missing permission shows up as an alert you fix in minutes rather than a silent broken code path discovered weeks later.
The AccessDenied alert is what makes the whole thing tolerable. When you scope too tightly, you find out immediately and specifically (role X denied s3:GetObject on bucket Y), and the fix is a one-line policy addition, not an archaeology project. Note that policy generation reviews denied events too, so re-running it after a tightening pass shows you what broke.
Permissions boundaries: cap the blast radius
Least privilege on individual roles is necessary; bounding what any role in a domain can do is the structural backstop. AWS permissions boundaries set a ceiling on what identity policies can grant, and SCPs set one for a whole account. The effective permission is the intersection: a request is allowed only if the identity policy, the boundary and the SCP all allow it, and an explicit deny in any of them wins. A boundary grants nothing on its own.
# Attach a boundary so this role can never exceed the dev ceiling
aws iam put-role-permissions-boundary \
--role-name my-service \
--permissions-boundary arn:aws:iam::123456789012:policy/DevBoundary
The patterns we use: an SCP that denies iam:* except for a designated admin role, an SCP that denies actions outside approved regions, and a boundary that every developer-created role must carry (enforced with an iam:PermissionsBoundary condition on iam:CreateRole). This decouples "who can grant permissions" from "how bad a mistaken grant can be." Developers can self-serve roles inside the boundary without each grant being a potential org-wide privilege escalation.
Kill static credentials while you're here
The biggest IAM improvement often isn't scoping a policy, it's eliminating long-lived keys entirely. A perfectly scoped static access key that leaks is still a standing liability. Replace static credentials with short-lived, federated ones:
- Workloads: instance and pod identity (IAM Roles for Service Accounts or EKS Pod Identity, GCP Workload Identity). No key to leak.
- CI/CD: OIDC federation, so the pipeline assumes a role per run with no stored secret. Our GitHub Actions security hardening guide shows the trust-policy conditions that keep that federation from being too broad.
- Humans: SSO plus short-session role assumption, not personal long-lived keys.
A credential that lives for 15 minutes and is scoped to one role is a much smaller attack surface than a perfectly written policy attached to a key that lives forever in someone's .env.
Questions people ask
How do I find unused IAM permissions in AWS?
Use IAM Access Analyzer policy generation, which reads up to 90 days of CloudTrail activity for a user or role and produces a policy template containing only the actions it saw. Pair it with service last-accessed data in the IAM console. Remember the gaps: S3 and other data events aren't analyzed at action level, and iam:PassRole is never included, so review those by hand before swapping policies.
What is the difference between a permissions boundary and an SCP?
A permissions boundary attaches to a single IAM user or role and caps what its identity policies can grant. An SCP attaches to an account or organizational unit in AWS Organizations and caps every principal in it. Neither grants permissions. A request succeeds only when the identity policy, the boundary and the SCP all allow it, and any explicit deny overrides.
How long should the observation window be before removing permissions?
At least 90 days, which is the maximum both AWS policy generation and GCP role recommendations use. Shorter windows miss monthly and quarterly jobs, and those failures surface weeks after the change when nobody remembers it. Anything annual falls outside every window, so ask owners about yearly jobs and keep AccessDenied alerting on in production.
The call we'd make
Least privilege is a direction you move continuously, not a state you reach once. Start from observed usage, add back the data events and PassRole grants the generator can't see, tighten in stages with AccessDenied alerting so mistakes are cheap and specific, and cap the worst case with boundaries and SCPs. If you only have time for one thing this quarter, kill static keys first: the best permission is the one that expires before an attacker can use it.
Sources
Cite this article
Kiril Urbonas (2026). Cloud IAM Least-Privilege Without Breaking Everything. DevOpsNess. Updated 2026-10-31. https://www.devopsness.com/blog/cloud-iam-least-privilege-without-breakage
[Cloud IAM Least-Privilege Without Breaking Everything](https://www.devopsness.com/blog/cloud-iam-least-privilege-without-breakage) (Kiril Urbonas, DevOpsNess, updated 2026-10-31)
More from Cloud
Explore more articles in this category
.NET 8 Support Ends November 10: The Lambda and Azure Functions Checklist
.NET 8, .NET 9, the Lambda dotnet8 runtime and the Azure Functions in-process model all lose support on November 10, 2026. Inventory commands, the isolated-worker migration and a CI gate.
Google Cloud's europe-west2 Outage: One Zone Lost Power and the Region Stalled for 6 Hours
On October 20, 2026, europe-west2-b lost power when its generators never started. Cross-zone control-plane timeouts then degraded the whole London region. What happened and the gcloud audit to run now.
Lambda Python 3.10 Is Deprecated Today: Find Every Function and Move to 3.13
AWS deprecated the Lambda python3.10 runtime on October 31, 2026. Functions keep running, patches stop. The inventory commands, IaC diffs and AL2023 gotchas for moving to python3.13.
You might have missed
Evergreen posts worth revisiting.