open atlas
↑ Back to track
Cloud & Infra Security CLOUD · 01 · 02

Least-privilege policies

A tight policy grants exactly the actions and resources a role needs and nothing more. Wildcards (`Action:*`, `Resource:*`) feel convenient and quietly become the blast radius. Permission boundaries cap what a role can ever grant — the guardrail least privilege needs at scale.

CLOUD Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

A service needs to read one config object from one S3 bucket. The ticket says “give the role S3 access,” so someone attaches AmazonS3FullAccess — a managed policy that grants s3:* on Resource: *. It works, the deploy goes green, everyone forgets about it. Two years later that same role’s access key leaks through a logged environment variable in a crash report. The attacker doesn’t get one config object. They get s3:GetObject on every bucket in the account, s3:DeleteBucket, s3:PutBucketPolicy to make any bucket public, and s3:PutObject to plant a payload in your static-site bucket. One wildcard, granted for a one-object read, became the entire account’s data plane. Nobody decided to give the service that power. The policy just never said no.

By the end of this lesson you’ll know how to write a policy that grants exactly the actions and resources a role needs, why wildcards turn a small leak into a large breach, and how a permission boundary caps the damage even when someone writes a sloppy policy anyway.

What a tight policy actually looks like

Least privilege is one rule: a principal should hold exactly the permissions required to do its job, for exactly as long as it needs them — and not one permission more. The hard part isn’t the principle, it’s that the convenient policy and the correct policy look almost identical, and the convenient one ships faster.

An IAM policy answers two questions for every request: which actions (s3:GetObject, dynamodb:Query) and on which resources (a specific bucket ARN, a specific table). A tight policy names both. The service that reads one config object should hold this:

{
  "Effect": "Allow",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::acme-config/service-x/*"
}

One action, one resource prefix. If that key leaks, the attacker gets read access to one folder in one bucket — a contained, boring incident. The blast radius is the policy, and you wrote the policy small on purpose.

The reflex to build is: start from deny, add the single action you can name, scope it to the single ARN you can name, and stop. Every time you reach for a *, you are saying “I don’t know which actions or resources this needs” — and an attacker who steals the credential knows exactly what to do with that uncertainty.

Why wildcards bite

Wildcards are the default failure mode because they are genuinely useful while you’re building and genuinely dangerous once you ship. There are two wildcards, and both hurt.

Action: "s3:*" widens what the principal can do. The service needed GetObject; the wildcard also handed it PutObject, DeleteObject, PutBucketPolicy, PutBucketAcl. Now a read credential can overwrite your data, delete it, or make a private bucket public. Resource: "*" widens where it applies — from one bucket to every bucket, including ones that don’t exist yet. The two compose: s3:* on * is full control of your entire object store from a credential that was provisioned to read a config file.

There’s a quieter cost too: wildcards make audit impossible. When a policy says s3:GetObject on a named ARN, a reviewer can read it and know precisely what the role can do. When it says s3:* on *, the only honest answer to “what can this role do?” is “everything to everything” — and nobody can reason about a blast radius that large. Tight policies are self-documenting; wildcards erase the documentation.

Why this works

Why does iam:* deserve special fear, above even s3:*? Because IAM permissions are self-amplifying. A role with iam:PutRolePolicy or iam:AttachRolePolicy can grant itself — or any role — any permission in the account. This is privilege escalation: the attacker doesn’t need the dangerous permission directly, only the permission to assign it. A role with iam:* and nothing else is effectively account-admin, because step one is to write itself an admin policy. This is exactly why the next guardrail — the permission boundary — exists: to make “grant yourself more” impossible even when iam:PutRolePolicy is present.

Permission boundaries: the guardrail least privilege needs at scale

Writing one tight policy is easy. Keeping every policy tight, across hundreds of roles and dozens of engineers who all have the power to create roles, is the real problem. This is what permission boundaries solve.

A permission boundary is a policy attached to a principal that sets the maximum permissions it can ever have — a ceiling, not a grant. The principal’s effective permissions are the intersection of its identity policies and its boundary: a permission is allowed only if both permit it. So even if a developer (or an attacker who has compromised that developer’s role) attaches AdministratorAccess to a new role, the boundary clips it back down to whatever the boundary allows.

The canonical use is delegated role creation. You let developers create roles for their own services — that’s good for velocity — but you require that every role they create carries a boundary you control. Now a developer can grant their service s3:GetObject on their bucket, but they cannot create a role with iam:* or access to the production database, because the boundary forbids it and the intersection wins. The boundary turns “trust every engineer to write a perfect policy” into “trust the one boundary policy, and let engineers move fast inside it.”

Identity policy saysBoundary saysEffective (intersection)Why
s3:GetObjects3:*s3:GetObjectBoth allow it; the narrower grant wins
s3:*s3:GetObject, s3:PutObjects3:GetObject, s3:PutObjectBoundary is the ceiling; the wildcard is clipped
iam:* (admin)no iam:*deniedEscalation blocked: boundary never permitted it
dynamodb:Querys3:* onlydeniedNot in the boundary → no intersection → no access

A boundary is not a substitute for tight policies — it’s a backstop. The identity policy is where you express intent (this role reads config); the boundary is where you express limit (no role in this account may ever touch IAM or prod data). You want both: tight policies so the common case is correct, and a boundary so a single sloppy policy can’t become an account compromise.

Reviewing a policy like a senior

When a policy crosses your review, you’re scanning for three things in order. First, wildcards: any Action: "*", "s3:*", or Resource: "*" is a question, not necessarily a bug — but it must be justified, and “it was easier” isn’t a justification. Second, the dangerous verbs: iam:*, iam:PutRolePolicy, iam:PassRole, sts:AssumeRole on a broad resource, *:Delete*, anything that can change other permissions or destroy data. Third, resource scope: does each statement name the specific ARNs it touches, or does it leave Resource: "*" because nobody enumerated them?

The senior move is to drive policies down over time, not just gate new ones. Cloud providers expose access-analyzer / last-accessed data: which actions a role has actually used in the last 90 days. A role granted s3:* that has only ever called GetObject and ListBucket is telling you its real policy — generate the tight one from observed usage and replace the wildcard. Least privilege isn’t a one-time grant you get right at creation; it’s a ratchet you tighten as you learn what the role actually does.

Pick the best fit

A service role currently has `AmazonS3FullAccess` (`s3:*` on `*`) but, per last-accessed data, has only ever called `s3:GetObject` and `s3:ListBucket` on one bucket. Pick the right way to bring it to least privilege.

Quiz

A role's only permission is `iam:PutRolePolicy` on `*`. It has no `s3:*`, no `dynamodb:*`, nothing else. How dangerous is it?

Quiz

A role has an identity policy allowing `s3:*` and a permission boundary allowing only `s3:GetObject` and `s3:ListBucket`. The role tries `s3:DeleteObject`. What happens and why?

Order the steps

Order the steps to take an over-permissioned role (`s3:*` on `*`) down to least privilege:

  1. 1 Pull last-accessed / access-analyzer data to see which actions the role actually used
  2. 2 Write a policy listing only those observed actions
  3. 3 Scope each statement's Resource to the specific ARNs the role touched
  4. 4 Replace the wildcard policy and attach a permission boundary as a ceiling
  5. 5 Re-check last-accessed after a cycle to catch any action you cut too aggressively
Recall before you leave
  1. 01
    Explain why a single wildcard like `s3:*` on `Resource: *` is so dangerous, even when the role only ever reads one object.
  2. 02
    What is a permission boundary, how does it differ from an identity policy, and why is it the control that makes least privilege survivable at scale?
Recap

Least privilege is one rule: a principal holds exactly the actions, on exactly the resources, it needs — and nothing more. The trap is that the convenient policy and the correct one look almost identical, and the convenient one (AmazonS3FullAccess, s3:* on *) ships faster. Wildcards are the dominant failure: Action: * widens what a role can do, Resource: * widens where, and together they turn a leaked read credential into full control of your object store while erasing any chance of auditing the blast radius. IAM verbs like iam:PutRolePolicy are worse still, because the permission to grant permissions is the permission to grant yourself everything — that’s privilege escalation. Permission boundaries are the guardrail that makes this survivable at scale: a ceiling whose intersection with the identity policy clips any over-grant, so delegated role creation stays fast without trusting every engineer to write a perfect policy. And least privilege is a ratchet, not a one-time grant — drive policies down toward observed last-accessed usage over time. Next time you see s3:* on a role that reads one bucket, your first question is: what has this role actually called, and what’s the one ARN I can scope it to?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.