Cruxly › Cloud and DevOps › Cloud & DevOps: Security
Least-Privilege IAM and the Blast Radius of Getting It Wrong
the Capital One pattern, and why broad roles are the default outcome
Open the interactive lessonPrompt: a service's IAM role currently has broad S3 access ("s3:*" on the account's buckets) because that was the fastest way to get it working. Design the least-privilege version, and explain specifically what real-world breach this change would have prevented, not just that it's "more secure" in the abstract.
30 minutes first. The naive framing treats least-privilege as a compliance checkbox. It's a specific, mechanistic defense against a specific, well-documented class of real breach.
Blast radius is defined by the permission, not the exploit
The 2019 Capital One breach is a concrete, documented case of blast radius mattering more than the initial vulnerability. The chain had three links:
- SSRF: a misconfigured web application firewall running on EC2 could be tricked into making HTTP requests on the attacker's behalf.
- Metadata credentials: the attacker pointed it at the instance metadata service (IMDSv1, at
169.254.169.254), which returned temporary credentials for the instance's IAM role. - Over-broad role: that role could list and read far more S3 buckets than a WAF ever needed, and the attacker copied the data of about 100 million people.
Each link had its own fix. IMDSv2 requires a session token obtained with a PUT request and, by default, a hop limit of 1. Typical SSRF flaws can't make that request, so it would have broken link 2, and it should be enforced across the account. A narrowly scoped role would have shrunk link 3. The vulnerability that let the attacker in was one thing; the scope of data exposed was defined entirely by a separate factor — how much that role could reach once assumed — which is precisely the distinction least-privilege targets: it doesn't prevent the initial compromise, it bounds what a compromise, once it happens, can actually reach.
This generalizes past this one incident: every identity — a user, a service role, a function's execution role — is a standing risk in proportion to what it can touch, independent of whether it's ever misused. A role with s3:* across every bucket in an account is a risk the day it's created, whether or not anything ever exploits it, because the potential blast radius is already as large as it will ever need to be for a future compromise to be maximally damaging.
Why broad roles are the default outcome, not the exception
Granting a broad role is fast; deriving and maintaining the correct narrow set of permissions takes real, ongoing effort — which is exactly why over-permissioning is the statistically common outcome across real cloud environments, not a rare lapse by an inattentive team. A service that needs to read from three specific S3 prefixes, granted blanket s3:* because scoping it precisely felt like unnecessary friction under a deadline, is the median case, not the exception — and it stays that way until something forces a review, which is usually a security audit or, worse, an incident.
The maintenance cost of least-privilege is real and has to be engineered around, not wished away — a permission set scoped too narrowly breaks the service the moment it needs one more action it wasn't granted, generating support tickets and a natural organizational pressure to over-grant "just in case" next time. The durable fix is treating permission requirements as part of a service's own definition (reviewed and updated as the service's actual needs change, ideally generated from real usage via access-analysis tooling that many cloud providers now offer) rather than a one-time grant made under time pressure and never revisited.
Standing access is a bigger risk than access requested just-in-time for a specific task and expiring afterward. A role with permanent broad access is a permanent blast radius; a role that can request temporary, scoped elevation for a specific operation and has that elevation expire automatically shrinks the window during which a compromise of that identity's credentials is actually dangerous, without requiring anyone to remember to revoke anything manually afterward.
No static keys, org-wide guardrails, and admission control
Workload identity removes the long-lived key to leak. A pod should never hold an AWS access key in a Secret. It should get short-lived credentials tied to its own Kubernetes service account:
- On EKS: IRSA, or the newer EKS Pod Identity.
- On GKE: Workload Identity Federation.
- On AKS: Microsoft Entra Workload ID.
The credentials expire within hours and are scoped per service account, not per node. That stops one pod from using another pod's role. It also closes the path the Capital One attacker used: if pods are blocked from the node's metadata endpoint, a compromised pod can't take the node's role.
Organization-level guardrails cap what any single role can do. On AWS:
- Service control policies (SCPs) set the maximum permissions for every principal in an account. A role can't use what an SCP denies, even if its own policy allows it.
- Resource control policies (RCPs, 2024) set the maximum access to resources such as S3 buckets and KMS keys, including access from outside the organization.
Together with conditions like aws:SourceVpc and aws:PrincipalOrgID, they form a data perimeter: even stolen credentials can't read your buckets from an attacker's own network or account.
In Kubernetes, admission control is where you enforce safe defaults:
- Pod Security Admission, built in since 1.25 where it replaced PodSecurityPolicy, enforces the
restrictedprofile per namespace. That profile blocks privileged containers and host mounts and requires running as a non-root user. - ValidatingAdmissionPolicy (GA in 1.30) lets you write custom rules in CEL without running a webhook, for example "every Deployment must set resource limits".
- Runtime detection such as Falco watches syscalls in running containers and catches what admission can't see, like a shell spawned inside a container or a read of
/etc/shadow.
Zero trust ties these together. Being inside the network grants nothing. Every request is authenticated by identity, whether that's a workload's identity or a user behind an identity-aware proxy, and authorized per request.
How to say it in the interview
"An over-permissioned role isn't a risk only if someone deliberately abuses it - it's a risk the moment it exists, because a compromise of the thing HOLDING that role inherits everything the role can reach, not just what the compromised thing actually needed. The Capital One breach is the textbook case: an SSRF flaw in a web application firewall let an attacker pull the instance role's credentials from the metadata service, that role could read far more S3 data than the WAF needed, and the breach's actual scope was defined by the role's permissions, not by the original vulnerability."
Checklist
- Scoped IAM roles to exactly the actions and resources a service needs, not a broad wildcard granted for convenience
- Understood an over-permissioned role is a standing risk regardless of whether it's ever actually misused
- Treated permission scoping as an ongoing maintenance task tied to a service's actual needs, not a one-time grant made under deadline pressure
- Used access-analysis tooling to derive actual usage rather than guessing at required permissions
- Preferred just-in-time, temporary elevation over permanent standing access where the access pattern allows it
- Required IMDSv2 on every instance and blocked pods from reaching the node's metadata endpoint
- Used workload identity (IRSA/EKS Pod Identity, GKE Workload Identity Federation) instead of static cloud keys in pods
- Capped every role with SCPs/RCPs and data-perimeter conditions, and enforced Pod Security Admission plus admission policies in clusters