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 Karunaratne⏱️ 20 min readUpdated
Share thisCopied
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

Type your AWS values once — every example below uses them.

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 6,144-character limit on customer managed policies. Inline policies have separate aggregate limits: 2,048 characters for a user, 5,120 for a group, 10,240 for a role. Larger policies must be split across multiple policies attached to the same role / user / group. AWS attaches up to 10 managed policies per principal by default (an adjustable quota).

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

Identity-based policies attach to an IAM user, group, or role and say "this principal can do X." The principal is implicit (whoever the policy is attached to), so the JSON does not include a Principal field.

Resource-based policies attach to a resource (S3 bucket, KMS key, SQS queue, Lambda function) and say "these principals can act on this resource." The Principal field is required. For cross-account access, both policies are needed: the IAM policy in the calling account allowing the action, and the resource policy in the target account allowing the principal.

Start from an empty policy and add only what's proven necessary. CloudTrail logs every API call (allowed and denied) for the principal; iterate by adding the actions and resources that are required, leaving everything else out. IAM Access Analyzer's policy-generation feature automates this from a CloudTrail window.

The two anti-patterns to avoid: Action: "*" with Resource: "*" (effectively AdministratorAccess), and pasting an AWS-managed policy that is wider than the principal needs. Specific verbs on specific ARNs, scoped with conditions where possible.

iam:PassRole is the action that grants permission to hand an existing IAM role to a service that will assume it. You need it whenever a user or role launches a resource that runs with an IAM role: EC2 with an instance profile, Lambda with an execution role, ECS tasks with task roles, CloudFormation with a service role.

Without iam:PassRole, the launch itself fails with "User is not authorized to perform: iam:PassRole on resource: ...". Scope the resource to the specific role ARN, and pair with the iam:PassedToService condition so the role can only be passed to the intended service.

Add a condition on aws:MultiFactorAuthPresent to the statement that grants the sensitive actions: "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}}. The action is allowed only when the request comes from an MFA-authenticated session.

For role-based access, put the MFA condition in the role's trust policy on sts:AssumeRole, optionally with aws:MultiFactorAuthAge to force re-authentication after N seconds. This is the standard hygiene for production admin roles: short sessions, MFA-gated assumption.

Yes, via aws:SourceIp in a Condition block. The pattern is usually an explicit Deny when the source is not in the allowed CIDR list, paired with "aws:ViaAWSService": "false" so service-to-service calls (Lambda invoking S3, CloudFormation calling EC2) aren't blocked.

Caveats: aws:SourceIp sees the public IPv4 address of the caller. If your users have IPv6, include the IPv6 CIDR too. If traffic goes through a VPC endpoint, the source IP is the endpoint's IP, not the user's; use aws:SourceVpce instead for that case.

Two condition keys: aws:ResourceTag/Key matches a tag on the target resource, and aws:PrincipalTag/Key matches a tag on the calling user or role. Combined, they produce dynamic policies like "engineers can manage resources tagged with their own team name" without writing per-team statements.

Tag-based access works only on actions that support resource tagging. Verify with the IAM service authorization reference for the specific service. Enforce a tagging baseline with AWS Config or service control policies so resources without the expected tags don't slip through unattended.

An explicit Deny overrides every Allow, in every policy, including from SCPs, permissions boundaries, and resource-based policies. If any policy in the evaluation chain has an explicit Deny on the action and resource, the request is denied regardless of how many Allow statements grant it elsewhere.

Use this as a guardrail: pair an Allow on the actions a role needs with an explicit Deny on the destructive subset, so a future broader policy attached to the same role can't accidentally unlock them. The Deny is the safety bar; the Allow is the working surface.

Run aws accessanalyzer validate-policy --policy-document file://policy.json --policy-type IDENTITY_POLICY. The output lists findings by severity: errors (malformed JSON, invalid action names), security warnings (over-permissive Resource: "*"), and suggestions (replace wildcard actions with specific ones actually used).

For testing whether a policy actually allows or denies a specific action, use the IAM policy simulator (console or aws iam simulate-principal-policy). Specify the principal, the action, the resource, and any context keys; the simulator returns the same Allow/Deny outcome the real authorization would.

Sources

Authoritative references this article was fact-checked against.

TagsAWSIAMSecurityLeast PrivilegePolicyDevOpsCloud SecurityAccess Control

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Software Systems Architect · Senior Software Engineer · Engineering Leadership

Software systems architect and senior software engineer with more than two decades designing, building, and running production software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Now a CTO, though what I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

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.

Exclude a directory in find with -path './node_modules' -prune -o ... -print. Why the trailing -print is mandatory, the multi-directory form, the slower -not -path alternative, and BSD vs GNU notes.

How to Exclude a Directory in find (the -prune Pattern Explained)

find -path './node_modules' -prune -o -type f -print skips a directory subtree instead of walking into it. The pattern looks strange because -prune is an action, not a test, and the trailing -print is mandatory once you write an explicit action. The breakdown, the multi-directory form, the slower -not -path alternative, and when each one is the right call.