Cruxly

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 lesson

Prompt: 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:

  1. SSRF: a misconfigured web application firewall running on EC2 could be tricked into making HTTP requests on the attacker's behalf.
  2. 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.
  3. 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:

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:

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:

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

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons