Skip to content

Lesson 1 of 9 · 25m

Reading an IAM policy like an attacker

Policy anatomy, the three evaluation rules that matter, the wildcards that leak, and how to simulate before you ship.

Most AWS incidents do not start with a zero-day. They start with a leaked access key and a policy that granted far more than anyone intended. Before you can write good IAM, you have to read it the way an attacker does: as a map of what a stolen credential can actually do.

Policy anatomy

Every IAM policy is a JSON document made of Statement objects. Each statement has four moving parts that matter:

  • Effect: Allow or Deny
  • Action: the API calls, e.g. s3:GetObject or iam:*
  • Resource: the ARN(s) the actions apply to
  • Condition: optional constraints that must hold true
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadAppLogs",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::acme-app-logs",
        "arn:aws:s3:::acme-app-logs/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "true" }
      }
    }
  ]
}

Two details in that small example trip people up constantly. ListBucket operates on the bucket ARN, GetObject on objects inside it — you need both ARNs, which is why the array has the bucket and bucket/*. And "Version": "2012-10-17" is a policy-language version stamp, not a date you should change to today.

Evaluation logic, the only three rules you need

  1. Everything is implicitly denied.
  2. An explicit Allow overrides the implicit deny.
  3. An explicit Deny overrides every allow, always, no matter where it comes from — identity policy, resource policy, SCP, permission boundary.

An attacker reads a policy asking "what's the widest thing I can reach?" You should audit the same way. The clauses that make policies wide:

  • "Action": "s3:*" — every S3 API on whatever resources are listed. Use action families like s3:Get* sparingly too; Get* includes GetBucketPolicy and GetObjectTagging, not just reads of objects.
  • "Resource": "*" — sometimes legitimately required (actions like iam:ListUsers or ec2:DescribeInstances do not support resource-level scoping), but on s3:* or iam:* it is almost always a mistake.
  • NotAction and NotResource — inverse matchers. "NotAction": "iam:*" means "every action except IAM," which is what people reach for when they mean "a few S3 actions." This is the bug that ships.

Checking what a policy really allows

The console's policy summary hides subtle interactions. Use the simulator:

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/app-runner \
  --action-names s3:DeleteObject iam:AttachRolePolicy \
  --resource-arns arn:aws:s3:::acme-app-logs

It returns allowed/implicitDeny/explicitDeny per action and — critically — tells you which statement produced the decision.

Exercise

Write a policy that is supposed to grant read-only access to one bucket. Then add "Action": "s3:*" and "Resource": "*" as a second statement (simulating what a teammate might paste from a blog). Run simulate-principal-policy for s3:DeleteObject, s3:PutBucketPolicy, and s3:GetObject against an unrelated bucket ARN. Observe which calls the "read-only" role can now make — then fix it by scoping Resource and splitting s3:Get*/s3:List* from writes.

Field notes

  • AWS managed AdministratorAccess and PowerUserAccess are not the same thing; PowerUser still blocks IAM mutation. Neither belongs on a workload role.
  • Inline policy size limits (roles: 10,240 chars; users: 2,048; managed policies: 6,144) will force you into multiple statements — that is fine.
  • A Deny in an SCP or permission boundary is how you carve "never allowed" zones that even an account admin cannot escape. Remember them when a permission looks impossible on paper but works in practice — and when it looks possible but fails.