IAM Policy JSON Anatomy: Read Every Field Before You Click Allow
An IAM policy is a small JSON document that answers one question: may this principal perform this action on this resource, under these conditions? Once you know the skeleton—Version, Statement, and the fields inside each statement—you can review policies in pull requests, debug AccessDenied errors, and design least privilege instead of copying console defaults.
In short
Every policy is a list of statements. Each statement has an Effect (Allow or Deny), optional Principal (resource policies only), Action/Resource (or their negated forms), and optional Condition. AWS starts from implicit deny; explicit Allow grants access unless something else Denies.
Where policies live (and what they attach to)
Before parsing JSON, know which policy type you are holding:
| Type | Attached to | Typical use |
|---|---|---|
| Identity-based | IAM user, group, or role | “What can this engineer or Lambda role do?” |
| Resource-based | S3 bucket, KMS key, SNS topic, etc. | “Who may access this bucket from another account?” |
| Permission boundary | User or role (max permissions cap) | Delegate policy writing without exceeding a ceiling |
| SCP (Organizations) | AWS account or OU | Guardrails that apply to every principal in the account |
| Session policy | Assumed role session | Temporary narrowing when assuming a role |
Identity policies omit Principal (the identity is implicit). Resource policies require Principal because the resource does not know who is knocking until you say so. The JSON shape is the same; which fields are valid depends on the policy type.
The document skeleton
Every IAM policy document has exactly two top-level keys:
{
"Version": "2012-10-17",
"Statement": [
{ /* one or more statement objects */ }
]
}
There is no third secret field. Tools, CloudFormation, and Terraform all emit this shape. If you see extra keys at the root, they are invalid for IAM (though some services wrap policies in larger configs).
Version
Always use "2012-10-17" for new policies. Older 2008-10-17 documents lack features such as NotAction, NotResource, and many condition keys. AWS still accepts the old version for legacy policies, but you should not author new ones with it.
Statement
An array of objects. IAM evaluates every applicable statement from every policy attached to the request path (identity policies, resource policy, SCPs, boundaries, session policies). Think of Statement as a list of rules, not a single rule.
One statement, field by field
Here is a annotated identity-based Allow with a condition—read it once, then we unpack each key:
{
"Sid": "ListAndReadOwnPrefix",
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::company-data-prod",
"arn:aws:s3:::company-data-prod/reports/*"
],
"Condition": {
"StringEquals": {
"aws:PrincipalTag/Team": "analytics"
}
}
}
Sid (optional but recommended)
Statement ID—a label for humans and diffs. It does not affect evaluation. Use short, unique names in shared repos (DenyUnencryptedPut, AllowEksDescribe) so reviewers can refer to a statement in comments.
Effect (required)
Either Allow or Deny. There is no “neutral.”
- Allow — grants permission if nothing else denies it.
- Deny — always wins over Allow in the same evaluation pass (explicit deny is trump card).
AWS applies implicit deny by default: if no statement Allows the request, access fails. You never write "Effect": "ImplicitDeny"; it is the baseline.
Principal (resource-based policies only)
Who the rule applies to. Formats:
"AWS": "arn:aws:iam::123456789012:root"— entire account (use carefully)."AWS": "arn:aws:iam::123456789012:role/AppRole"— a specific role."Service": "lambda.amazonaws.com"— an AWS service principal."Federated": "arn:aws:iam::123456789012:saml-provider/CorpIdP"— web identity / SAML.
NotPrincipal is the negated form (rare; use when you want “everyone except X”). For identity policies, omit Principal entirely—the attached user or role is the principal.
Action and NotAction
API operations, in the form service:Operation:
s3:GetObject,ec2:DescribeInstances,iam:PassRole- Wildcards:
s3:Get*,ec2:Describe*,*(avoid*on production roles)
NotAction means “all actions except these.” Example pattern: Allow everything except IAM and billing:
"Effect": "Allow",
"NotAction": [
"iam:*",
"aws-portal:*",
"account:*"
],
"Resource": "*"
You cannot mix Action and NotAction in the same statement. Pick one style per statement.
Resource and NotResource
Amazon Resource Names (ARNs) identify what the action targets. Examples:
arn:aws:s3:::my-bucket/path/*
arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123
arn:aws:iam::123456789012:role/AppRole
arn:aws:kms:us-east-1:123456789012:key/abcd-ef01-2345-6789
Patterns:
*— all resources (common forDescribe*actions that do not take a resource ARN in the API).- Region or account placeholders in some services; always check the service’s IAM reference for which resources an action supports.
- ListBucket vs GetObject:
s3:ListBucketapplies to the bucket ARN;s3:GetObjectapplies to object ARNs (bucket/*). Mixing them up is a top exam and production mistake.
NotResource excludes specific ARNs while allowing the rest (useful in large Allow statements with a small deny list—though explicit Deny statements are often clearer).
Condition (optional, powerful)
Conditions refine when a statement applies. Structure:
"Condition": {
"<operator>": {
"<condition-key>": <value>
}
}
Multiple operators in one Condition block are ANDed. Multiple keys under one operator are also ANDed unless the operator documentation says otherwise.
Common operators:
| Operator | Meaning | Example key |
|---|---|---|
StringEquals | Exact string match | aws:SourceVpc |
StringLike | Wildcard match | s3:prefix → reports/* |
ArnEquals / ArnLike | ARN comparison | aws:SourceArn |
Bool | true / false | aws:SecureTransport |
IpAddress / NotIpAddress | CIDR check | aws:SourceIp |
DateGreaterThan, etc. | Time windows | aws:CurrentTime |
ForAllValues:StringEquals | Multi-value tag keys | aws:TagKeys |
High-value condition keys:
aws:MultiFactorAuthPresent— require MFA for sensitive operations.aws:SecureTransport— deny if not HTTPS (Bool→falsein a Deny statement).aws:PrincipalArn,aws:userid— tie access to a specific role session.aws:PrincipalTag/Key— ABAC: permissions follow resource or principal tags.aws:SourceVpc,aws:SourceVpce— restrict to private network paths.s3:x-amz-server-side-encryption— enforce KMS or AES256 on uploads.
Example: deny S3 puts without encryption:
{
"Sid": "DenyInsecureObjectUploads",
"Effect": "Deny",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::company-data-prod/*",
"Condition": {
"StringNotEquals": {
"s3:x-amz-server-side-encryption": "aws:kms"
}
}
}
How AWS decides yes or no (evaluation order)
When a request hits AWS, the policy engine roughly follows this mental model:
- Organizations SCP — can only deny or limit maximum permissions; never grants access by itself.
- Resource-based policy — if present, can Allow cross-account access early.
- Identity-based policies — all attached managed and inline policies for the user/role.
- Permission boundary — intersects with identity policies (caps what identity policies can grant).
- Session policy — further narrows assumed-role sessions.
Rules of thumb:
- Explicit Deny anywhere applicable → request fails.
- No Allow that matches → implicit deny (fail).
- Allow that matches, and no Deny → success (for that part of the request).
Some APIs need multiple actions and resources (e.g. kms:Decrypt plus key policy Allow). If you fix the identity policy but still see AccessDenied, check the resource policy and VPC endpoint policy too.
Managed, inline, and JSON in the wild
- AWS managed policies — curated ARNs like
arn:aws:iam::aws:policy/ReadOnlyAccess. Good starting points; rarely least privilege for production workloads. - Customer managed policies — your JSON, versioned, attachable to many identities. Prefer these for reusable teams and CI review.
- Inline policies — embedded on one user/role/group. Harder to reuse; use sparingly for one-off exceptions.
In Terraform or CloudFormation, the policy document is often built from a data "aws_iam_policy_document" or intrinsic functions—the evaluated JSON is what IAM stores. Always inspect the final JSON in the console or with aws iam get-role-policy when debugging.
Trust policies are policies too (roles)
An IAM role has two documents: permissions policies (what the role can do) and a trust policy (who can assume the role). Trust policies use the same JSON grammar but only allow sts:AssumeRole, sts:AssumeRoleWithWebIdentity, etc., with a Principal:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
Confusing trust policy with permissions policy is a common source of “the role exists but nothing can assume it” tickets.
Least privilege patterns that stay readable
- Split statements by intent — one Sid for read, one for write; easier review than one mega-statement.
- Prefer actions over
*— start from AWS managed job-function policies, then trim with IAM Access Analyzer or last-accessed data. - Scope resources tightly — account-level ARNs for IAM roles; bucket + prefix for data plane.
- Use conditions for guardrails — MFA, source VPC, encryption headers, tag-based ABAC.
- Deny lists for non-negotiables — disabling regions, denying
iam:*except break-glass roles, enforcing TLS.
Worked example: CI role for ECR and EKS
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AuthToEcr",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken"
],
"Resource": "*"
},
{
"Sid": "PushPullOneRepo",
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "arn:aws:ecr:ap-south-1:123456789012:repository/my-app"
},
{
"Sid": "EksDescribeForDeploy",
"Effect": "Allow",
"Action": [
"eks:DescribeCluster"
],
"Resource": "arn:aws:eks:ap-south-1:123456789012:cluster/prod"
}
]
}
Note ecr:GetAuthorizationToken requires Resource": "*" per AWS’s IAM model—that is normal, not a sign of laziness. The push/pull statement is scoped to one repository ARN.
Common mistakes (and what error messages hint)
- Wrong ARN partition or region — copy/paste from us-east-1 into ap-south-1 policies.
- Missing
iam:PassRole— CloudFormation, ECS, and Lambda need PassRole on the execution role ARN, not just service APIs. - Action/resource mismatch —
logs:CreateLogGroupon a log stream ARN;kms:Decryptwithout key policy Allow. - SCP deny looks like identity deny — check Organizations before rewriting role policies.
- Overly broad
NotActionAllow — “allow everything except IAM” still allows data exfiltration APIs unless boundary + SCP back it up.
Validate before production
- IAM policy simulator — test Allow/Deny for a principal against sample actions and resources.
- Access Analyzer — flags unused actions and external access in resource policies.
- CloudTrail —
errorCodeanderrorMessageon failed API calls show which statement class failed (sometimes with policy type). - JSON lint — trailing commas and duplicate keys break console saves; CI should validate JSON schema where possible.
How this fits your AWS security study path
Policy JSON is the language behind everything in Cloud Security Foundations and landing-zone guardrails in Cloud Architecting. Pair this post with network architecture when you use aws:SourceVpc conditions, and with GitOps when IAM policies live in Git beside Terraform modules.
Further reading
- AWS IAM documentation — policy reference, condition keys, and evaluation logic
- AWS Well-Architected Security pillar — identity and access management best practices
- IAM Access Analyzer — external access and unused permission findings
Blog index · Cloud Security Foundations · Cloud Architecting