TechEarl

AWS IAM Policy Examples: S3, EC2, Lambda, and Least-Privilege Patterns

A working library of AWS IAM policy examples: S3 read-only with prefix, EC2 admin scoped to a region, Lambda execute-but-not-write, MFA-required, IP-restricted, VPC-endpoint-only, tag-based prod-vs-dev isolation, and the iam:PassRole pattern. Plus the anatomy of a policy document and how Access Analyzer narrows over-permissive Resource: "*" grants.

Ishan KarunaratneIshan Karunaratne⏱️ 20 min readUpdated
AWS IAM policy examples by use case: S3 read-only with prefix, S3 read-write with delete denied, EC2 admin scoped to a region via aws:RequestedRegion, Lambda execute and read env vars but not write, iam:PassRole for service-linked roles, MFA-required via aws:MultiFactorAuthPresent, IP-restricted via aws:SourceIp, VPC-endpoint-only via aws:SourceVpce, tag-based prod-vs-dev isolation via aws:ResourceTag, plus the anatomy of a policy document and IAM Access Analyzer for least-privilege validation.

The AWS console will happily attach the AdministratorAccess managed policy to a new user and call it done. That's almost never what you want for anything beyond a personal sandbox. The actually-useful IAM policies are the ones that say "this role can do this specific thing on this specific resource, under these specific conditions" and stop there. This post is a working library of those policies: copy-pasteable JSON for the common patterns (S3 read-only on a prefix, EC2 admin in one region, MFA-required, IP-restricted, tag-based isolation), plus the explanations for why each Condition key exists and what the alternatives are.

How does an AWS IAM policy work?

An IAM policy is a JSON document with one or more Statement blocks. Each statement has an Effect (Allow or Deny), a list of Action values (the API calls being controlled), a list of Resource ARNs the actions apply to, and an optional Condition block that further constrains when the statement matches. AWS evaluates a request against every applicable policy (identity-based, resource-based, permissions boundaries, SCPs) and the request is allowed only if at least one statement explicitly Allows it and no statement explicitly Denies it. Default-deny: if nothing Allows, the action is denied. The version string "2012-10-17" is the current policy language version and has been since 2012; always use it for new policies. Common condition keys: aws:MultiFactorAuthPresent (require MFA), aws:RequestedRegion (restrict to a region), aws:SourceIp (restrict by client IP, with IPv6 + VPC-endpoint caveats), aws:SourceVpce (restrict to a VPC endpoint), aws:PrincipalTag and aws:ResourceTag (tag-based access). The most-common over-grant: Resource: "*" when a specific ARN would do. The most-common gap: forgetting iam:PassRole for actions that hand a role to a service. Validate with IAM Access Analyzer before deploying anything wider than a sandbox.

Try it with your own values

Jump to:

Anatomy of a policy document

Every IAM policy follows the same five-field structure:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowReadOnlyExample",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ],
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": "us-east-1"
        }
      }
    }
  ]
}

The fields:

  • Version: always "2012-10-17" for new policies. Older versions exist but only 2012-10-17 supports modern policy features like variables (${aws:username}) and the current condition key syntax.
  • Statement: an array of one or more statement objects. Statements are evaluated independently and combined with logical OR for Allow, AND for Deny.
  • Sid: optional, human-readable label. Has no functional effect but shows up in CloudTrail and Access Analyzer findings; use it.
  • Effect: Allow or Deny. Default is implicit deny; explicit Deny overrides every Allow, including from other policies.
  • Action: list of API actions (service:Operation). Wildcards are allowed (s3:Get*, ec2:*). Spelling is case-insensitive but conventional capitalization is lowerCamelCase.
  • Resource: list of ARNs the statement applies to. Wildcards are allowed (arn:aws:s3:::my-bucket/* matches every object in the bucket).
  • Condition: optional. Map of condition operators (StringEquals, IpAddress, Bool, etc.) to key-value pairs that further constrain the statement.

Evaluation logic: AWS gathers every policy that could apply (identity policies, resource policies, permissions boundary, SCPs). The request is allowed if there is at least one Allow and zero Denies. An explicit Deny anywhere in the chain wins, no matter how many Allows exist.

Identity-based vs resource-based policies

Two policy types, both JSON, attached to different things:

  • Identity-based policies attach to an IAM user, group, or role. They say "this principal can do X." The Principal field is not present (the principal is whoever the policy is attached to). Examples in this post are mostly identity-based.
  • Resource-based policies attach to a resource (S3 bucket, KMS key, SQS queue, Lambda function, etc.). They say "this resource can be acted on by Y." The Principal field is required (specifies who's being granted access). The canonical example is an S3 bucket policy.

A cross-account S3 request needs both: the IAM policy on the user in account A allowing s3:GetObject on arn:aws:s3:::bucket-in-account-b/*, AND a bucket policy on the bucket in account B allowing the user's ARN to perform s3:GetObject. Either alone is not enough.

For same-account access, only the identity policy is needed (the bucket's implicit policy allows the bucket owner's account to act on it).

Example 1: S3 read-only on a specific bucket and prefix

The most common "give the developer access to their own data" pattern:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBucketWithPrefix",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::my-bucket",
      "Condition": {
        "StringLike": {
          "s3:prefix": ["uploads/*"]
        }
      }
    },
    {
      "Sid": "ReadObjectsUnderPrefix",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:GetObjectVersion"],
      "Resource": "arn:aws:s3:::my-bucket/uploads/*"
    }
  ]
}

Two statements. The first allows listing the bucket but only the contents of the uploads/ prefix (the s3:prefix condition key is what restricts the listing). The second allows reading individual objects under that prefix. Without statement 1, listing fails; without statement 2, listing succeeds but GetObject denies.

The bucket ARN (arn:aws:s3:::my-bucket) and the object ARN (arn:aws:s3:::my-bucket/*) are different and apply to different actions. Bucket-level actions (ListBucket, GetBucketLocation) use the bucket ARN; object-level actions (GetObject, PutObject) use the object ARN. Mixing them up produces silent "AccessDenied" responses.

Example 2: S3 read-write with delete denied

For a service account that should be able to write but never destroy data:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowReadWrite",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ]
    },
    {
      "Sid": "DenyDeletes",
      "Effect": "Deny",
      "Action": [
        "s3:DeleteObject",
        "s3:DeleteObjectVersion",
        "s3:DeleteBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ]
    }
  ]
}

The explicit Deny is the safety bar. Even if some other policy (a group policy, an organizational SCP) grants s3:DeleteObject, this Deny statement wins because Deny always overrides Allow.

The pattern generalizes: pair Allow on the actions the principal needs with explicit Deny on the destructive subset of the same service. It's the cheapest insurance against an attached managed policy accidentally including too much.

Example 3: EC2 admin in a specific region

EC2 administration but only in us-east-1, using the aws:RequestedRegion global condition key:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EC2AdminUSEast1",
      "Effect": "Allow",
      "Action": "ec2:*",
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": "us-east-1"
        }
      }
    }
  ]
}

aws:RequestedRegion is the region the API call is targeting (the regional endpoint hit, or the explicit --region flag value). It applies to almost every regional service and is the right tool for "this team only operates in this region."

The Resource: "*" here is unavoidable because EC2 resource ARNs are region-scoped; the region condition does the limiting. For services where resource ARNs include the region in the path (like SQS), restricting the Resource ARN to a region pattern is an alternative.

Caveat: global services (IAM, CloudFront, Route 53, Organizations) do not respect aws:RequestedRegion. Adding it to those denies every action, which is sometimes desirable (block IAM modifications from a regional team) and sometimes not.

Example 4: Lambda execute and read env vars, deny writes

A read-only invoker role for Lambda that can invoke any function in the account and read its configuration, but not modify code or environment variables:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvokeAndReadConfig",
      "Effect": "Allow",
      "Action": [
        "lambda:InvokeFunction",
        "lambda:GetFunction",
        "lambda:GetFunctionConfiguration",
        "lambda:ListFunctions",
        "lambda:ListVersionsByFunction"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DenyAllWrites",
      "Effect": "Deny",
      "Action": [
        "lambda:CreateFunction",
        "lambda:UpdateFunctionCode",
        "lambda:UpdateFunctionConfiguration",
        "lambda:DeleteFunction",
        "lambda:PutFunctionConcurrency",
        "lambda:AddPermission",
        "lambda:RemovePermission",
        "lambda:TagResource",
        "lambda:UntagResource"
      ],
      "Resource": "*"
    }
  ]
}

GetFunctionConfiguration returns the environment variables, which is often the point of read-only Lambda access (debugging an invocation by inspecting what the function sees). If that's too sensitive, swap GetFunctionConfiguration for ListFunctions only, which returns names without env vars.

Example 5: iam:PassRole for service-linked roles

The bug everyone hits the first time they automate EC2 launches: a user has ec2:RunInstances but the launch fails with "User is not authorized to perform: iam:PassRole on resource: ..." because the launch tries to attach an instance profile to the new instance.

The fix:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "LaunchEC2Instances",
      "Effect": "Allow",
      "Action": [
        "ec2:RunInstances",
        "ec2:DescribeInstances",
        "ec2:DescribeImages",
        "ec2:DescribeKeyPairs",
        "ec2:DescribeSecurityGroups",
        "ec2:DescribeSubnets",
        "ec2:DescribeVpcs"
      ],
      "Resource": "*"
    },
    {
      "Sid": "PassEC2InstanceProfile",
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::123456789012:role/EC2-Application-Role",
      "Condition": {
        "StringEquals": {
          "iam:PassedToService": "ec2.amazonaws.com"
        }
      }
    }
  ]
}

iam:PassRole is the action that grants the right to attach an existing role to a service. The iam:PassedToService condition restricts which service the role can be passed to; without it, the user could pass the role to any service that accepts a role (Lambda, ECS, Glue, etc.), which is rarely intended.

The same pattern applies to creating Lambda functions (iam:PassRole on the function's execution role), creating ECS task definitions, and dozens of other "create a thing and give it an existing IAM role" actions.

Example 6: MFA required for sensitive actions

Force MFA for any destructive or privileged action:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowReadAlways",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket",
        "ec2:Describe*"
      ],
      "Resource": "*"
    },
    {
      "Sid": "AllowWriteWithMFA",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:DeleteObject",
        "ec2:TerminateInstances",
        "ec2:StopInstances"
      ],
      "Resource": "*",
      "Condition": {
        "Bool": {
          "aws:MultiFactorAuthPresent": "true"
        }
      }
    }
  ]
}

aws:MultiFactorAuthPresent is true when the session was authenticated with MFA. For IAM users with MFA enabled, this means they ran aws sts get-session-token with --serial-number and --token-code before issuing the actual API call. For federated identities (SSO), it depends on the IdP signaling MFA in the SAML assertion.

A common variant for the trust policy of an admin role:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::123456789012:root"},
      "Action": "sts:AssumeRole",
      "Condition": {
        "Bool": {"aws:MultiFactorAuthPresent": "true"},
        "NumericLessThan": {"aws:MultiFactorAuthAge": "3600"}
      }
    }
  ]
}

aws:MultiFactorAuthAge restricts the role to sessions where MFA was performed within the last hour. Forces re-authentication on long sessions, which is the right hygiene for production admin roles.

Example 7: IP-restricted access

Lock access to a known CIDR range, like the office VPN exit IPs:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyAccessOutsideCorpIPs",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "NotIpAddress": {
          "aws:SourceIp": [
            "203.0.113.0/24",
            "198.51.100.0/24",
            "2001:db8::/32"
          ]
        },
        "Bool": {
          "aws:ViaAWSService": "false"
        }
      }
    }
  ]
}

Three subtleties:

  • IPv6. If your office or VPN supports IPv6, list the IPv6 CIDR alongside the IPv4 one. NotIpAddress accepts both. A common bug: enforcing IPv4 only, then the user's machine prefers an IPv6 route and the deny fires because the IPv6 source isn't in the allowed list.
  • aws:ViaAWSService. When one AWS service calls another on your behalf (Lambda calling S3, CloudFormation calling EC2), the source IP is an internal AWS address, not your IP. aws:ViaAWSService: false excludes those service-to-service calls from the deny so legitimate cross-service automation keeps working.
  • VPC endpoints. Traffic through an interface or gateway VPC endpoint also doesn't show your office IP; it shows the VPC endpoint IP. For VPC-only access, use aws:SourceVpce (next example) instead of aws:SourceIp.

Example 8: VPC-endpoint-only access

For a service that should only be reachable from inside your VPC:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyAccessOutsideVPC",
      "Effect": "Deny",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::my-bucket",
        "arn:aws:s3:::my-bucket/*"
      ],
      "Condition": {
        "StringNotEquals": {
          "aws:SourceVpce": "vpce-0123456789abcdef0"
        }
      }
    }
  ]
}

This goes on the bucket policy, not an identity policy. Resource-based deny on the bucket: every S3 request must come through VPC endpoint vpce-0123456789abcdef0 or it's blocked.

Common pairing: the same bucket has a public-block setting that prevents accidental public ACLs, plus this VPC-endpoint deny that prevents any access from the public internet at all. Even a leaked access key can't pull data out unless the attacker is inside the VPC.

aws:SourceVpc exists too (matches any VPC ID, not a specific endpoint). Use aws:SourceVpce when you have a specific endpoint per environment; use aws:SourceVpc when you want any endpoint in a given VPC to qualify.

Example 9: Tag-based prod vs dev isolation

Use tags to keep developers out of production resources without writing per-resource ARN lists:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ManageDevResources",
      "Effect": "Allow",
      "Action": [
        "ec2:StartInstances",
        "ec2:StopInstances",
        "ec2:RebootInstances",
        "ec2:TerminateInstances"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:ResourceTag/Environment": "dev"
        }
      }
    },
    {
      "Sid": "DenyProdMutations",
      "Effect": "Deny",
      "Action": [
        "ec2:TerminateInstances",
        "ec2:StopInstances"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:ResourceTag/Environment": "prod"
        }
      }
    }
  ]
}

aws:ResourceTag/Environment reads the value of the Environment tag on the target resource. The first statement allows the four actions only when the resource is tagged Environment=dev; the second statement explicitly denies the destructive subset on anything tagged Environment=prod.

The companion key aws:PrincipalTag/Team matches a tag on the calling principal (the user or role). Combined with aws:ResourceTag/Team it produces "principal can act on resources tagged with the same team":

json
"Condition": {
  "StringEquals": {
    "aws:ResourceTag/Team": "${aws:PrincipalTag/Team}"
  }
}

Powerful, terse, and only works if your tagging discipline is real. Resources without an Environment tag fall outside both statements and become uncontrolled. Use AWS Config rules or service control policies to enforce that every resource carries the tags your IAM policies expect.

Example 10: Least-privilege starter (deny-all base)

The pattern for starting from zero and adding only what's proven necessary:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowSpecificActions",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::approved-bucket",
        "arn:aws:s3:::approved-bucket/*"
      ]
    }
  ]
}

There is no statement saying "deny everything else." That's the default. IAM is deny-by-default; an identity with this single Allow statement attached can do exactly two things on one bucket, nothing more.

The workflow that produces these policies:

  1. Start with no policy attached. Observe the application or person trying to use the account.
  2. CloudTrail logs each access denial. For each denied action, decide whether it should be allowed.
  3. If yes, add the action and resource to the policy. Re-test.
  4. If no, fix the application to stop trying.
  5. After a few cycles, the policy converges on the minimum surface needed.

Tedious for a person, fast with IAM Access Analyzer's policy-generation feature (next section), which automates the CloudTrail-to-policy step.

The Resource: "*" red flag

Resource: "*" is sometimes correct and often a smell. The correct cases:

  • Actions that don't take a resource ARN (ec2:DescribeInstances, s3:ListAllMyBuckets, most Describe* and List* actions).
  • A Condition block limits the scope (region, tag, source IP).
  • The intent is genuinely service-wide (an admin role for IAM itself).

The smelly cases:

  • s3:GetObject with Resource: "*" (every object in every bucket in the account).
  • kms:Decrypt with Resource: "*" (every KMS key, including ones you don't know exist).
  • iam:PassRole with Resource: "*" (any role can be passed to any service; catastrophic in the wrong hands).

When you see Resource: "*" in a policy, ask: would this still work with a specific ARN list? If yes, narrow it. If the only reason * is there is "we didn't know the list at policy-write time," that's the smell.

Validating with IAM Access Analyzer

IAM Access Analyzer (launched at re:Invent 2019) does two things relevant to this post:

  • Policy validation flags syntax errors, redundant statements, and suggestions to narrow over-permissive policies. Run it on every new policy before attaching:
bash
aws accessanalyzer validate-policy \
  --region :region \
  --policy-document file://policy.json \
  --policy-type IDENTITY_POLICY

The output lists findings by severity: errors block deployment (malformed JSON, invalid action), security warnings flag dangerous patterns (Resource: "*" on a destructive action), suggestions recommend tightening (replace s3:* with the specific verbs actually used).

  • Policy generation from CloudTrail observes what a principal actually did over a time window and generates a least-privilege policy from the logs:
bash
aws accessanalyzer start-policy-generation \
  --region :region \
  --policy-generation-details principalArn=arn:aws:iam:::account_id:role/MyRole \
  --cloud-trail-details \
      accessRole=arn:aws:iam:::account_id:role/AccessAnalyzerCloudTrailAccess,startTime=2026-01-01T00:00:00Z

After it completes (a few minutes), retrieve the generated policy:

bash
aws accessanalyzer get-generated-policy --region :region --job-id <id>

The generated policy is a starting point, not the final answer. Review it manually: it includes only the actions performed during the observation window, so anything that ran less frequently (monthly job, quarterly maintenance) won't appear.

Pair Access Analyzer with the broader Account-level External Access Analyzer, which scans your account for policies that grant access to external principals (other accounts, public, anyone signed in to AWS). The findings catch the common "I made the bucket public for one quick test" mistake.

Common pitfalls

1. Forgetting the bucket ARN for ListBucket. s3:ListBucket operates on the bucket itself (arn:aws:s3:::my-bucket), while s3:GetObject operates on objects (arn:aws:s3:::my-bucket/*). Granting only the object ARN to ListBucket returns AccessDenied with no useful error.

2. Wildcards in Action and Resource simultaneously. Action: "*" / Resource: "*" is AdministratorAccess by another name. Narrow at least one side, almost always both.

3. Deny is forever, even from other policies. An explicit Deny in an SCP, permissions boundary, or any attached policy overrides every Allow. Useful for guardrails; bites when debugging.

4. The Principal field on identity policies. Identity-based policies do not take a Principal (it's whoever the policy is attached to). Adding one is a JSON validation error. Resource-based policies (bucket policies, KMS key policies, role trust policies) require it.

5. aws:SourceIp doesn't see your IP through a VPC endpoint. Traffic via VPC interface or gateway endpoint shows the endpoint's IP, not the source's. Use aws:SourceVpce or aws:SourceVpc for in-VPC restrictions.

6. Tag-based conditions on actions that don't support resource tagging. Not every AWS API supports aws:ResourceTag. Check the IAM action table for the service before betting a policy on it; the unsupported-action case silently grants when you expected a deny.

7. The 10,240-character limit on managed policies and 2,048 on inline. Larger policies must be split across multiple policies attached to the same role / user / group. AWS attaches up to 10 managed policies and an unlimited inline budget per principal.

8. Variables like ${aws:username} only resolve in identity-based policies. They work for IAM users; for federated identities via STS, use ${aws:PrincipalTag/SomeTag} instead. Resource-based policies have their own variable scope.

9. Conditions are case-sensitive on keys but operator semantics matter. StringEquals vs StringLike (wildcards) vs StringEqualsIgnoreCase. The IAM docs spell out each; the wrong operator silently fails the match.

10. Permissions boundaries are not policies that grant; they cap. Attaching a boundary that allows s3:* doesn't grant s3:*; it sets the maximum any identity policy can grant. Boundaries are AND'd with identity policies; the result is the intersection.

What to do next

FAQ

TagsAWSIAMSecurityLeast PrivilegePolicyDevOpsCloud SecurityAccess Control
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

Bash arrays reference: declaration, indexing, [@] vs [*] quoting, iteration, appending, slicing, mapfile/readarray for lines, IFS-based string splitting, plus macOS Bash 3.2 limits.

Bash Arrays: Indexed, Associative, and Iteration Patterns

Bash array reference: indexed and associative declaration, the [@] vs [*] quoting gotcha, iterating values and indexes, appending, slicing, deleting, mapfile/readarray for reading lines, and the macOS Bash 3.2 vs Linux Bash 4+ differences.

Bash for loop reference: brace-range {1..10}, sequence (seq), array, glob, C-style, nested, parallel with xargs. Plus safe file iteration with find -print0, globbing pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ differences.

Bash For Loops: Syntax, Examples, and One-Liners

Every form of the Bash for loop with working examples: brace-range, sequence-expression, array, glob, C-style, nested, and parallel. Plus the safe file-iteration patterns, common pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ gotchas.