Anatomy of a Policy Document
An IAM policy is a JSON document that answers one question: who can do what, to which resources, under what conditions. AWS Identity and Access Management (IAM) reads this document every time someone tries an action and decides “allow” or “deny.” Understanding each field by name turns policy writing from guesswork into something you can reason about precisely. This page dissects every element with real examples.
Here is a complete policy. We will pull it apart field by field.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowReadOnlyBucketObjects",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::devcraftly-reports",
"arn:aws:s3:::devcraftly-reports/*"
],
"Condition": {
"StringEquals": {
"aws:PrincipalTag/team": "analytics"
}
}
}
]
}
Version
Version is the policy language version, not the date you wrote the file. It tells IAM which grammar rules to apply. Always use "2012-10-17" (the latest version). The older "2008-10-17" does not support policy variables or many condition features, so never use it for new work.
Tip: The value must be the literal string
"2012-10-17". It has nothing to do with the current year and you should not change it.
Statement
Statement is the heart of the policy. It is either a single statement object or an array of them. Each statement is one independent rule. A policy is the sum of all its statements: IAM evaluates every statement, and a single explicit Deny anywhere wins over any Allow.
When to use multiple statements: group permissions that share the same effect, actions, and resources into one statement; create a separate statement when the resource list or conditions differ. This keeps each rule readable.
Sid
Sid (Statement ID) is an optional human-readable label for a statement, like "AllowReadOnlyBucketObjects". It does nothing functionally for identity-based policies. Its value is making policies self-documenting and easier to find in logs and the policy simulator. Use clear, unique Sids; skip them only for trivial one-line policies.
Effect
Effect is required and must be exactly "Allow" or "Deny". By default everything is implicitly denied, so most statements use Allow to grant access. Use Deny to carve out exceptions, for example “allow everything in this region except deleting production buckets.” Remember the golden rule: an explicit Deny always beats an explicit Allow.
Action
Action lists the API operations the statement covers, written as service:Operation, such as s3:GetObject or ec2:StartInstances. It can be a single string or an array.
Wildcards are allowed:
| Pattern | Meaning |
|---|---|
s3:GetObject | One specific action |
s3:Get* | Every S3 action starting with “Get” |
s3:* | Every action in the S3 service |
* | Every action in every service (admin-level, avoid) |
Use the narrowest pattern that does the job. s3:Get* is fine for a read role; * should be reserved for break-glass admin roles only.
Resource
Resource names the AWS objects the actions apply to, written as Amazon Resource Names (ARNs — globally unique IDs for AWS resources). It can be a string or an array, and ARNs may contain * wildcards.
This is where most policies break. For Amazon S3 (Simple Storage Service), the bucket and the objects inside it are two different ARNs:
| ARN | What it refers to |
|---|---|
arn:aws:s3:::devcraftly-reports | The bucket itself (used by s3:ListBucket) |
arn:aws:s3:::devcraftly-reports/* | Every object inside the bucket (used by s3:GetObject, s3:PutObject) |
Gotcha:
s3:GetObjectacts on objects, so it needs the/*ARN.s3:ListBucketacts on the bucket, so it needs the bare bucket ARN. Listing only the bucket ARN with an object action (or vice versa) silently denies access with a confusing “Access Denied.” This bucket-vs-objects mistake is the single most common S3 policy bug. Include both ARNs when a role both lists and reads.
Condition
Condition is an optional block that makes a statement apply only when extra tests pass, such as a matching tag, a source IP range, or MFA being present. It is structured as { "Operator": { "Key": "Value" } }.
"Condition": {
"StringEquals": { "aws:PrincipalTag/team": "analytics" },
"IpAddress": { "aws:SourceIp": "203.0.113.0/24" }
}
Multiple operators in one Condition are joined with AND — every test must pass. When to use this: to enforce guardrails like “only from the office network” or “only with MFA.” See the dedicated conditions page for the full operator list.
Principal
Principal declares who the policy applies to. It appears only in resource-based policies (like S3 bucket policies, SQS queues, or trust policies on roles) — never in identity-based policies attached to a user, group, or role.
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:role/AnalyticsRole" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::devcraftly-reports/*"
}
A role’s trust policy uses Principal to say which service or account is allowed to assume it, for example { "Service": "ec2.amazonaws.com" }.
Creating and validating a policy with the CLI
Save the policy JSON to a file, then create a managed policy:
aws iam create-policy \
--policy-name AnalyticsS3ReadOnly \
--policy-document file://analytics-read.json
Output:
{
"Policy": {
"PolicyName": "AnalyticsS3ReadOnly",
"PolicyId": "ANPAEXAMPLE0A1B2C3D4E",
"Arn": "arn:aws:iam::123456789012:policy/AnalyticsS3ReadOnly",
"DefaultVersionId": "v1",
"AttachmentCount": 0,
"CreateDate": "2026-06-15T10:24:00+00:00"
}
}
You can also create policies in the console:
- Open the IAM console and choose Policies in the left nav.
- Click Create policy, then switch to the JSON tab.
- Paste the document. The editor flags syntax errors and unknown actions inline.
- Click Next, give it a name and description, then Create policy.
Tip: IAM is global and has no cost — you are never charged for policies, users, or roles. Run
aws accessanalyzer validate-policy --policy-document file://analytics-read.json --policy-type IDENTITY_POLICYto catch mistakes (including the S3 bucket-vs-object trap) before you deploy.
Best practices
- Always set
Versionto"2012-10-17"so policy variables and modern conditions work. - Give every meaningful statement a clear
Sidso logs and the policy simulator are readable. - Prefer specific actions and the narrowest wildcard that works (
s3:Get*overs3:*, never*except for admin). - For S3, list both the bucket ARN and the
bucket/*object ARN when a role needs to list and read. - Use
Conditionblocks to add guardrails like MFA, source IP, or tag matching. - Validate with IAM Access Analyzer before attaching, and test real requests with the policy simulator.
- Reserve explicit
Denystatements for hard guardrails, remembering that aDenyalways overrides anyAllow.