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:

TypeAttached toTypical use
Identity-basedIAM user, group, or role“What can this engineer or Lambda role do?”
Resource-basedS3 bucket, KMS key, SNS topic, etc.“Who may access this bucket from another account?”
Permission boundaryUser or role (max permissions cap)Delegate policy writing without exceeding a ceiling
SCP (Organizations)AWS account or OUGuardrails that apply to every principal in the account
Session policyAssumed role sessionTemporary 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 for Describe* 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:ListBucket applies to the bucket ARN; s3:GetObject applies 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:

OperatorMeaningExample key
StringEqualsExact string matchaws:SourceVpc
StringLikeWildcard matchs3:prefixreports/*
ArnEquals / ArnLikeARN comparisonaws:SourceArn
Booltrue / falseaws:SecureTransport
IpAddress / NotIpAddressCIDR checkaws:SourceIp
DateGreaterThan, etc.Time windowsaws:CurrentTime
ForAllValues:StringEqualsMulti-value tag keysaws:TagKeys

High-value condition keys:

  • aws:MultiFactorAuthPresent — require MFA for sensitive operations.
  • aws:SecureTransport — deny if not HTTPS (Boolfalse in 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:

  1. Organizations SCP — can only deny or limit maximum permissions; never grants access by itself.
  2. Resource-based policy — if present, can Allow cross-account access early.
  3. Identity-based policies — all attached managed and inline policies for the user/role.
  4. Permission boundary — intersects with identity policies (caps what identity policies can grant).
  5. 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 mismatchlogs:CreateLogGroup on a log stream ARN; kms:Decrypt without key policy Allow.
  • SCP deny looks like identity deny — check Organizations before rewriting role policies.
  • Overly broad NotAction Allow — “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.
  • CloudTrailerrorCode and errorMessage on 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

Back to blog list