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.
Jump to:
- Anatomy of a policy document
- Identity-based vs resource-based policies
- Example 1: S3 read-only on a specific bucket and prefix
- Example 2: S3 read-write with delete denied
- Example 3: EC2 admin in a specific region
- Example 4: Lambda execute and read env vars, deny writes
- Example 5: iam:PassRole for service-linked roles
- Example 6: MFA required for sensitive actions
- Example 7: IP-restricted access
- Example 8: VPC-endpoint-only access
- Example 9: Tag-based prod vs dev isolation
- Example 10: Least-privilege starter (deny-all base)
- The
Resource: "*"red flag - Validating with IAM Access Analyzer
- Common pitfalls
- What to do next
- FAQ
Anatomy of a policy document
Every IAM policy follows the same five-field structure:
{
"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 only2012-10-17supports 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:AlloworDeny. Default is implicit deny; explicitDenyoverrides 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 islowerCamelCase.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
Principalfield 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
Principalfield 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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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.
NotIpAddressaccepts 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: falseexcludes 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 ofaws:SourceIp.
Example 8: VPC-endpoint-only access
For a service that should only be reachable from inside your VPC:
{
"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:
{
"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":
"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:
{
"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:
- Start with no policy attached. Observe the application or person trying to use the account.
- CloudTrail logs each access denial. For each denied action, decide whether it should be allowed.
- If yes, add the action and resource to the policy. Re-test.
- If no, fix the application to stop trying.
- 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, mostDescribe*andList*actions). - A
Conditionblock limits the scope (region, tag, source IP). - The intent is genuinely service-wide (an admin role for IAM itself).
The smelly cases:
s3:GetObjectwithResource: "*"(every object in every bucket in the account).kms:DecryptwithResource: "*"(every KMS key, including ones you don't know exist).iam:PassRolewithResource: "*"(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:
aws accessanalyzer validate-policy \
--region :region \
--policy-document file://policy.json \
--policy-type IDENTITY_POLICYThe 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:
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:00ZAfter it completes (a few minutes), retrieve the generated policy:
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
- Change an AWS EC2 instance type for the EC2 admin pattern with
iam:PassRolefor the EBS-snapshot and AMI flows. - AWS S3 cp and sync cheat sheet for the read-only and read-write S3 policies in action.
- SSH into an EC2 instance for the SSM Session Manager pattern that replaces SSH key management.
- Add an EBS volume to an EC2 instance for the volume attach permissions and the tag-based volume isolation pattern.
- Extend an EBS volume without restart for the
ec2:ModifyVolumepermission in a least-privilege ops role. - Cross-cloud: SSH into a GCP VM without gcloud, GCP add persistent disk to VM, and Increase a Google Cloud VM disk size without rebooting for the GCP IAM analogs.
- Scripting context: Bash for loop and Bash while loop for batch-applying policies across many roles; SSH cheat sheet for the bastion + SSM pattern; curl cheat sheet for hitting AWS endpoints directly when the CLI gets in the way.
- External: IAM identity-based policy examples, AWS global condition keys, IAM Access Analyzer documentation.



![Bash arrays reference: declaration, indexing, [@] vs [*] quoting, iteration, appending, slicing, mapfile/readarray for lines, IFS-based string splitting, plus macOS Bash 3.2 limits.](https://techearl.com/cdn-cgi/image/width=1536,format=auto,quality=80/https://images.techearl.com/bash-arrays/bash-arrays.jpg?v=2026-02-12T14%3A18%3A00Z)

