The question this answers
How do I read a policy and tell, in under a minute, whether it grants more than the workload needs?
An image worker reads uploads and writes thumbnails. It must never delete customer records and must never read billing data. Somebody has to be able to confirm from the policy alone that both of those are true.
A readable structure — effect, action, resource, condition — that makes an over-broad grant visible to a reviewer instead of visible to an attacker.
Four parts, and the one that does the damage
Every policy statement has the same four parts. The effect is allow or deny. The action is what may be done. The resource is what it may be done to. The condition is an optional narrowing — source network, request attribute, resource tag, time.
Action and resource are where policies go wrong, and they go wrong asymmetrically. A wildcard action (storage:*) is at least visible: a reviewer sees the asterisk and asks about it. A wildcard *resource* hides better, because it often looks specific. arn:…:user-media/* reads like "the uploads bucket" and grants the entire bucket including every other prefix. secret/* reads like "our secrets" and grants every secret in the account, including the ones another team created last week.
The condition is the part teams skip and the part that buys the most for the least effort. A read grant restricted to requests originating inside your virtual network is dramatically less useful to an attacker who has the credential but not the network position. A write grant conditioned on a resource tag prevents a service from touching resources it was never assigned. Conditions cost one line and shrink blast radius more than most policy rewrites do.
A policy document is worth reading as monospace text at least once, because that is how the wildcards become visible. The block below is one real-shaped policy for one workload.
policy: image-worker
statement 1 effect: ALLOW
action: storage:GetObject
resource: user-media/uploads/* <- prefix, not bucket
condition: source_vpc = vpc-app
statement 2 effect: ALLOW
action: storage:PutObject
resource: user-media/thumbnails/* <- different prefix
condition: source_vpc = vpc-app
statement 3 effect: DENY <- guardrail; beats any allow
action: storage:DeleteObject
resource: *
statement 4 effect: DENY
action: db:*, secrets:*
resource: *
reads as: "may read an upload and write a thumbnail, from inside the app network,
and may never delete an object, touch a database or read a secret."Needed versus allowed, on the same panel
The single most useful review technique in this module is to write the two lists next to each other. On the left, what the workload does — derived from the code, or from the audit trail of what it actually called last month. On the right, what the policy permits. Any line on the right without a partner on the left is unearned privilege.
The panel below is the well-scoped version. The needed list and the allowed list correspond one to one, the denies are guardrails rather than corrections, and the network condition means a stolen credential is not immediately usable from outside. A reviewer can approve this in five seconds, which is the real bar: policies that take fifteen minutes to reason about do not get reviewed, they get approved.
Note what the denies buy. DeleteObject denied at the account level means that even if a later engineer adds a broad allow to fix an unrelated problem, deletion is still impossible for this identity — because an explicit deny beats any allow. That is the mechanism guardrails are built from, and it is why they belong on the identities you are least able to review often.
- storage:GetObject on user-media/uploads/* (source_vpc = vpc-app)
- storage:PutObject on user-media/thumbnails/* (source_vpc = vpc-app)
- read the uploaded original from uploads/
- write the generated thumbnail to thumbnails/
- storage:DeleteObject on *
- db:* on * (cannot read or delete customer records)
- secrets:* on * (cannot read billing credentials)
Blast radius: A stolen credential used from inside the application network can read user uploads and write objects into the thumbnails prefix. It cannot delete anything, cannot reach any database, and cannot read any secret. Used from outside the network the condition fails and it grants nothing at all.
The same workload, after eight months of incidents
Policies do not start broad. They become broad, one urgent fix at a time, and each individual step is defensible. The worker needs to read a second prefix, so someone drops the prefix and grants the bucket. A migration needs deletes for one afternoon, so the deny is removed and never restored. A new feature needs one secret, so secrets:GetSecretValue on * gets added because finding the exact ARN at 23:40 was hard.
Nobody made a bad decision. The end state is still a workload that can read every secret in the account and delete any object in any bucket, and no single change request would have been rejected on review. This is why permission drift needs a scheduled owner rather than a review gate: the gate passes every time.
Two mechanical defences work. Generate policies from infrastructure code so that widening a permission is a reviewed diff rather than a console click, and it reverts on the next apply (Drift: When the File and Reality Disagree). And use the audit trail to compare granted permissions against permissions actually exercised over a 90-day window — the gap is a concrete, unarguable list of grants to remove.
| Change | Why it happened | What it actually granted | Blast radius after |
|---|---|---|---|
uploads/* → user-media/* | The worker needed a second prefix | Every object in the bucket, including other tenants' originals | Read all user media |
| Removed the DeleteObject deny | A one-afternoon migration needed deletes | Permanent delete on every bucket | Destroy all user media |
Added secrets:GetSecretValue on * | A new feature needed one secret at 23:40 | Every secret in the account | Database, payment provider, signing keys |
Added db:Connect on * | Debugging a data issue in staging | Every database the role can reach, including production | Full customer data access |
| Removed the source_vpc condition | A batch job ran outside the network | The credential now works from anywhere | A stolen credential is usable from the internet |
Key points
- Effect, action, resource, condition. Wildcards in the resource are more dangerous than wildcards in the action because they hide better.
- Write needed and allowed side by side; every allowed line without a matching needed line is unearned privilege.
- A condition — source network, resource tag, request attribute — is one line and shrinks blast radius more than most rewrites.
- An explicit deny beats any allow, which makes deny statements the right tool for guardrails on identities you cannot review often.
- Policies drift wide through a series of individually reasonable changes; the defence is generation from code plus a periodic granted-versus-used comparison.
- A policy a reviewer cannot judge in five seconds will be approved rather than reviewed.
The loop, answered
Every field is required, which is why no lesson here can recommend something without saying what it costs and what simpler thing to consider first.
- • The policy engine collects every applicable statement — identity policies, resource policies, group policies, boundaries and organization-level constraints.
- • Any matching explicit deny ends the evaluation immediately, regardless of how many allows exist.
- • Otherwise at least one allow must match the exact action and the exact resource, with every condition satisfied.
- • If nothing matches, the default deny applies and the caller receives an access-denied error naming the action.
- • The decision is written to the audit trail, which is what makes the granted-versus-used comparison possible later.
- • Own a review habit that reads the resource line first, and asks what the wildcard actually matches.
- • Own the guardrail denies for destructive and cross-domain actions, applied at a level individual teams cannot edit.
- • Own a scheduled granted-versus-used report; without one, permissions only ever grow.
- • Own the emergency-widening runbook, including the ticket that removes the grant afterwards. The removal is the part that does not happen by itself.
- • Own policy generation from infrastructure code, so a console change shows up as drift rather than as the new normal.
- • A resource wildcard matching far more than intended —
user-media/*instead ofuser-media/uploads/*is one character and an entire bucket. - • A deny that blocks a legitimate action and produces a confusing failure, because deny wins and nothing explains which statement fired.
- • A condition on a source address that breaks when the workload moves zones or the egress address changes.
- • Permission drift: eight months of individually reasonable widenings ending at effective administrator.
- • A policy so long that reviewers approve it without reading, which is functionally the same as having no review.
- • Emergency access granted during an incident and never revoked, which is the single most common finding in an access audit.
- • Policy count grows with workloads × environments; hand-writing stops being viable long before it stops being attempted.
- • Generation from infrastructure code is the mechanism that scales, because the policy lives next to the workload that justifies it.
- • Broad shared roles are the alternative that appears to scale and actually just merges every blast radius into one.
- • Review attention is the resource that runs out first — which is an argument for short policies rather than for fewer of them.
- • The gap between needed and allowed is exactly the attacker's working set after a compromise.
- • Deny statements are the strongest control available, because nothing downstream can grant around them.
- • Conditions turn a stolen credential into a credential that only works from one network, which defeats a large class of exfiltration.
- • Separate destructive actions from read actions in policy design; they are different powers and are almost never needed by the same workload.
- • Every widening should be traceable to a change and an author. See Audit Trails and Least Privilege in Infrastructure.
- • No direct cost. The expenditure is review time, and it is the cheapest security spend available.
- • The indirect cost of a broad policy is incident scope: a compromise with a wildcard policy takes days to bound instead of minutes.
- • Audit-log analysis for granted-versus-used has a real query cost at scale — budget it as a recurring control, not a one-off.
- • Access-denied errors by identity and action, which reveal both a missing grant and an attacker probing the edges of one.
- • Permissions granted but never exercised in 90 days — the actionable list for narrowing.
- • Policy diffs as first-class events: who widened what, in which change, and with what justification.
- • Use of any identity holding a wildcard resource grant, which should be rare enough to be individually interesting.
- • The signal that lies: no access-denied errors at all. That usually means the policy is too broad, not that it is correct.
- • Provider-managed predefined roles for common workloads. Broader than a hand-written policy, reviewed by the provider, and far better than a wildcard written under pressure.
- • Start from a deny-everything guardrail plus a small allow list, and let deployment failures tell you what is actually needed — noisier at first, and it converges on a genuinely minimal policy.
- • Generate the policy from observed calls in a staging environment, then review it. Faster than reasoning from source, and it catches the code paths nobody remembered.
- • For a two-resource prototype, one simple scoped policy and no taxonomy at all. Policy engineering should be proportional to what there is to protect.
- • Narrow policies buy small blast radii; they cost deployment failures whenever the workload legitimately needs something new.
- • Conditions buy strong contextual limits; they cost fragility when the context legitimately changes — a new zone, a new egress address.
- • Guardrail denies buy irreversibility in the right direction; they cost the ability to make a fast exception during an incident.
- • Generated policies buy reviewability and drift correction; they cost a slower path for urgent changes, which is both the point and genuinely painful at 03:00.
Needed vs granted: an IAM policy evaluated
PutObject on thumbnails/*
| Attempted action | Needed? | Result | Why |
|---|---|---|---|
| GetObject uploads/2026/cat.jpg read the original | yes | ALLOW | allowed by g1 The job it exists to do. |
| PutObject thumbnails/2026/cat_256.jpg write the thumbnail | yes | ALLOW | allowed by g2 The other half of the job. |
| GetObject uploads/legal/master-agreement.pdf read a legal document | no | DENY | explicit deny · d1 ← beats every allow Same bucket, different prefix. The worker never needs this, and the compliance team assumed nothing could reach it. |
| GetObject invoices/2026-Q1.csv read another bucket entirely | no | ALLOW | allowed by g3 A different bucket in the same account. Only a wildcard resource reaches it. |
| PutObject uploads/2026/cat.jpg overwrite its own input | no | DENY | no matching allow · implicit deny If the worker can write where it reads, a compromised worker can poison the input of every other consumer of that bucket. |
| DeleteObject uploads/2026/cat.jpg delete a customer upload | no | DENY | no matching allow · implicit deny Irreversible unless versioning is on. Nothing in the code calls delete. |
What people believe, and what is true
Wildcards are fine as long as the action is narrow.
A narrow action on every resource in the account is still a large grant. secrets:GetSecretValue on * reads every secret you have.
The policy was reviewed when it was written.
It was reviewed in its original form. What is running now is the original plus every urgent widening since, and nothing reviewed the sum.
No access-denied errors means the permissions are right.
It usually means they are too broad. A correctly-scoped system produces occasional denials when something new is attempted.