IAM policies are short JSON documents, but every character matters. A missing /* or a wrong condition operator is the difference between "works" and "AccessDenied", or worse, between "least privilege" and "anyone can delete production". This guide walks through each part of a policy so you can read any policy and write your own.
The anatomy of a policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadReports",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::acme-reports",
"arn:aws:s3:::acme-reports/*"
],
"Condition": {
"Bool": {"aws:SecureTransport": "true"}
}
}
]
}
- Version: always
2012-10-17. It is the policy language version, not a date you change. The older version does not support policy variables. - Statement: a list of rules. Each is evaluated independently.
- Sid: an optional label that makes the policy readable and easier to debug.
Effect
Allow or Deny. Requests are denied by default; an Allow grants access; an explicit Deny in any applicable policy always wins. Use Deny for guardrails ("never delete this bucket", "never act outside these Regions") rather than for routine permissions.
Action
Actions are written service:Operation, for example ec2:StartInstances or dynamodb:Query. Wildcards are allowed: s3:Get* matches every S3 action starting with Get. Be careful: wildcards also match actions AWS adds in the future.
NotAction inverts the list: "everything except these actions". Combined with Allow it grants a lot, so it is usually paired with Deny, for example denying everything except IAM and STS when MFA is missing.
Resource
Resources are identified by their Amazon Resource Name (ARN):
arn:partition:service:region:account-id:resource
Some details that trip people up:
- S3 buckets vs objects.
s3:ListBucketapplies to the bucket ARN (arn:aws:s3:::acme-reports);s3:GetObjectapplies to objects (arn:aws:s3:::acme-reports/*). Most "I can list but not download" problems come from listing only one of them. - Global services like IAM have an empty Region field:
arn:aws:iam::123456789012:user/alice. - Some actions do not support resource-level permissions (many
Describe*andList*calls) and need"Resource": "*". The Service Authorization Reference lists which resources and condition keys each action supports.
Condition
Conditions add context checks. The structure is operator → key → value:
"Condition": {
"StringEquals": {"aws:RequestedRegion": ["eu-west-1", "eu-central-1"]},
"IpAddress": {"aws:SourceIp": "203.0.113.0/24"}
}
Multiple operators in one Condition block must all match (AND). Multiple values for one key match if any value matches (OR).
Useful condition keys:
| Key | Use |
|---|---|
aws:RequestedRegion | Limit actions to approved Regions |
aws:MultiFactorAuthPresent | Require MFA for sensitive actions |
aws:SourceVpce / aws:SourceVpc | Only allow requests through your VPC endpoint |
aws:PrincipalTag/…, aws:ResourceTag/… | Attribute-based access control (team owns resources with its tag) |
aws:SecureTransport | Reject plain HTTP requests |
iam:PolicyArn | Allow attaching only specific policies |
Watch the operator. StringEquals is exact; StringLike supports wildcards; ArnLike and ArnEquals compare ARNs. The ...IfExists suffix makes a condition pass when the key is absent, which is handy but easy to misuse.
Policy variables
Variables let one policy adapt to whoever uses it. The classic example gives each user their own S3 "home folder":
"Resource": "arn:aws:s3:::team-home/${aws:username}/*"
Variables only work with "Version": "2012-10-17".
Principal (resource-based policies only)
Identity-based policies have no Principal: the identity they are attached to is the principal. Resource-based policies, such as S3 bucket policies, KMS key policies or SQS queue policies, name who is allowed:
"Principal": {"AWS": "arn:aws:iam::111122223333:role/analytics"}
For cross-account access, both sides must agree: the resource policy must allow the other account's principal, and that principal's own identity policy must allow the action.
Evaluation logic in one picture
- Is there an explicit Deny in any applicable policy? Then deny.
- Do SCPs, permission boundaries or session policies fail to allow it? Then deny.
- Does an identity-based or resource-based policy allow it? Then allow.
- Otherwise, deny (implicit).
Five common mistakes
- Using
"Resource": "*"with write actions because a single action needed it. - Forgetting the bucket ARN (for ListBucket) or the
/*object ARN (for GetObject). - Writing a condition on a key the action never sends, so the statement never matches.
- Adding a broad Deny for safety that also blocks your own admin role.
- Testing only with an admin user. Always test as the actual user or role, or use the IAM Policy Simulator.
Practice it
Try Resource Policies: S3 Bucket Policy vs Lambda Execution Role to see identity and resource policies working together, and Attribute-Based Access Control with Session Tags for advanced conditions. The incident lab Order Writer Gets AccessDenied from DynamoDB is a good debugging exercise.
FAQ
Does a Deny always win?
Yes. An explicit Deny in any policy that applies to the request overrides every Allow.
How do I test a policy before deploying it?
Use the IAM Policy Simulator in the console, or IAM Access Analyzer's policy validation, which flags errors and overly broad statements.
What is the maximum size of a policy?
Managed policies can be up to 6,144 characters (excluding whitespace); inline policies have their own per-identity limits. Large policies usually mean it is time to split responsibilities into separate roles.