Why architecture matters here

IAM without conditions is coarse. Same role, different context — dev vs prod, MFA vs no MFA — and you either over-grant or spawn ten similar roles. Conditions let one policy serve many contexts correctly.

The architecture matters because conditions interact with SCPs, resource policies, and boundaries. A permitted action at the identity policy must also survive the SCP and the resource policy. Tools like Access Analyzer and Policy Simulator help you prove what actually resolves to allow.

With the pieces mapped, IAM becomes maintainable at scale rather than a source of chronic audit findings.

Advertisement

The architecture: every piece explained

The top strip is the policy shape. Principal is the identity making the call. Policy statement combines Action + Resource + Condition. Context keys reference request context (aws:CurrentTime, aws:SourceIp) and service-specific keys. Tag conditions match on tags — aws:RequestTag (tags being applied) vs aws:PrincipalTag (tags on the caller) vs aws:ResourceTag (tags already on the resource).

The middle row is the evaluation surface. MFA + IP conditions restrict actions to authenticated sessions from approved networks. SCP overlay applies org-level guardrails that trump identity policies. Resource policy (bucket policies, KMS key policies) applies at the resource side; effective access is the intersection. Boundary caps what an identity can be effectively granted.

The lower rows are validation. Policy simulator lets you input a principal + action + context and see the answer. Access Analyzer reports unused permissions and external access. Ops covers policy versioning, tests, change review, and drift detection.

AWS IAM conditions — context keys, tag conditions, SCP interaction, policy simulatorleast-privilege that adapts to contextPrincipalrole or userPolicy statementaction + resource + conditionContext keysaws:* + service:*Tag conditionsaws:RequestTag / aws:PrincipalTagMFA + IPsession-time contextSCP overlayorg guardrailsResource policybucket / KMS keysBoundarycap on grantsPolicy simulatorprove effective accessAccess Analyzerunused + externalOps — versioned policies + tests + change review + drift detectionMFAguardraillayercapsimulateanalyzeanalyzeoperateoperate
AWS IAM conditions and the surrounding evaluation surfaces.
Advertisement

The Condition block is an AND of ORs

A Condition element is three levels deep, and the boolean meaning of each level is different. The outer level maps condition operators to blocks. Inside an operator block, each entry maps a condition key to one value or an array of values. The rules are: values inside one key's array are combined with OR; keys inside one operator block are combined with AND; and operator blocks are combined with AND. Every part must be true or the statement does not match at all - and a statement that does not match contributes neither an allow nor a deny.

"Condition": {
  "StringEquals": {
    "aws:PrincipalTag/team":  "platform",          // AND with the next key
    "aws:RequestedRegion":    ["eu-west-1", "eu-central-1"]   // OR inside the list
  },
  "Bool":              { "aws:SecureTransport": "true" },     // AND with the block above
  "NumericLessThan":   { "aws:MultiFactorAuthAge": "3600" }
}

The practical consequence is that one statement cannot express an OR across two different keys. "Allow if the call comes from the office range or from a principal in our organization" is not writable as a single Condition; it is two Allow statements, because allows are unioned at the policy level. People routinely write both keys into one block, ship it, and then discover that only callers satisfying both survive. The mirror-image mistake shows up in denies: two keys in one deny block means the deny fires only when both are true, which is a far narrower guardrail than intended.

Negated operators invert the whole comparison, not the list. AWS documents the value list under a negated operator as a logical NOR: it is true only when every value comparison is false. So "StringNotEquals": {"aws:RequestedRegion": ["eu-west-1","eu-central-1"]} reads as "the region is neither of these". That is exactly what you want for a region-lock deny, and exactly what surprises people who read it as "not eu-west-1 or not eu-central-1", which would be trivially true everywhere.

Operators, by the type of value they compare

Operators are typed, and using the wrong family against a key is one of the few genuinely silent failures in IAM: a type mismatch does not error, it simply never matches. Access Analyzer's policy validation catches many of these, which is a good argument for running it in CI.

FamilyOperatorsNotes
StringStringEquals, StringNotEquals, StringEqualsIgnoreCase, StringNotEqualsIgnoreCase, StringLike, StringNotLikeCase-sensitive unless you pick the IgnoreCase form. Only the Like variants accept * and ?.
NumericNumericEquals, NumericNotEquals, NumericLessThan, NumericLessThanEquals, NumericGreaterThan, NumericGreaterThanEqualsValues are still JSON strings: "3600", not 3600.
DateDateEquals, DateNotEquals, DateLessThan, DateLessThanEquals, DateGreaterThan, DateGreaterThanEqualsISO 8601 (2026-01-01T00:00:00Z) or epoch seconds. Used with aws:CurrentTime / aws:EpochTime.
BooleanBoolCompares against the strings "true" / "false".
BinaryBinaryEqualsBase64-encoded comparison; rare outside a few service keys.
IP addressIpAddress, NotIpAddressCIDR notation, IPv4 and IPv6. A bare address is treated as a /32.
ARNArnEquals, ArnLike, ArnNotEquals, ArnNotLikeWildcards apply within each of the six colon-delimited ARN segments and do not spill across them.
PresenceNullNot a value comparison - see the next section.

The ARN family is worth using deliberately. StringLike on an ARN lets a single * swallow account IDs, regions and resource paths at once, so arn:aws:s3:::prod-* written with StringLike is looser than most authors realise. ArnLike keeps wildcards inside segment boundaries, which is what you almost always meant.

Condition values also accept policy variables: ${aws:PrincipalTag/team}, ${aws:userid}, ${aws:PrincipalAccount}. Substitution happens at evaluation time, which is what makes a single ABAC policy cover every team. If the referenced key is absent from the request, the variable does not resolve to empty string - the statement simply fails to match.

Missing keys, IfExists, and Null - where policies fail open

Not every request carries every key. This is the single most important fact about AWS conditions, and it is where policies quietly invert their meaning. When a key is absent from the request context:

Positive-match operators (StringEquals, IpAddress, Bool, ArnLike) evaluate to false. The statement does not match. An Allow guarded this way fails closed; a Deny guarded this way fails open, because the deny never fires.

Negated operators (StringNotEquals, NotIpAddress, ArnNotLike) evaluate to true on an absent key. The statement matches. Now an Allow fails open and a Deny fails closed. Both directions are wrong for somebody.

The ...IfExists suffix can be appended to any operator except Null, and it means "if the key is present, apply the operator; if it is absent, return true." The canonical use is MFA enforcement. aws:MultiFactorAuthPresent only appears in requests signed with temporary credentials; a request signed with a long-term IAM user access key does not carry the key at all - it is absent, not false. So this deny does not stop access-key users:

{ "Effect": "Deny", "Action": "*", "Resource": "*",
  "Condition": { "Bool": { "aws:MultiFactorAuthPresent": "false" } } }   // misses long-term keys

{ "Effect": "Deny", "Action": "*", "Resource": "*",
  "Condition": { "BoolIfExists": { "aws:MultiFactorAuthPresent": "false" } } }   // correct

The same trap runs the other way. An IP-restriction deny written with NotIpAddressIfExists lets through every request that carries no aws:SourceIp at all - which includes calls arriving through a VPC endpoint and calls an AWS service makes on your behalf. Sometimes that is precisely the exemption you want; if you did not think about it, it is a hole.

The Null operator tests presence directly and is the tool for making the rule explicit. "Null": {"aws:TagKeys": "false"} means "the key must be present in the request" (the request must carry at least one tag key); "true" means "the key must be absent". Pairing a Null check with the real comparison is more verbose than IfExists but leaves no ambiguity about which side of the missing-key boundary you chose.

ForAllValues and ForAnyValue - the empty-set trap

Some context keys are multivalued: a single request supplies a set of values, not one. aws:TagKeys carries every tag key in a tagging request; aws:PrincipalOrgPaths and aws:CalledVia are sets; services add their own, such as DynamoDB's dynamodb:LeadingKeys and dynamodb:Attributes. Comparing a set to a list needs a quantifier prefix on the operator.

ForAnyValue: is true when at least one value in the request set matches at least one policy value. If the key is absent, it is false.

ForAllValues: is true when every value in the request set matches some policy value. Critically, it is also true when the request set is empty or the key is absent - vacuous truth, exactly as in logic.

That last line is the trap. A ForAllValues condition on an Allow statement does not constrain a request that carries none of the key's values, so the allow applies unconditionally to exactly the requests you never enumerated. AWS flags this case explicitly and prescribes the fix: always pair a ForAllValues condition with a Null check set to "false", which forces the key to be present before the set comparison can matter. The mirror case in a Deny is over-restrictive rather than dangerous, which is why it tends to surface in staging instead of in an incident review. ForAnyValue in a Deny has the opposite shape - an absent key means no match, so the deny silently does not fire - and gets the same Null treatment.

The clean, canonical use is restricting which tag keys anyone may set, with the Null guard in place:

{ "Effect": "Allow",
  "Action": ["ec2:CreateTags", "ec2:DeleteTags"],
  "Resource": "arn:aws:ec2:*:*:instance/*",
  "Condition": {
    "ForAllValues:StringEquals": { "aws:TagKeys": ["project", "env", "owner"] },
    "Null": { "aws:TagKeys": "false" }        // key must be present
  } }

Read it as: the request must name at least one tag key, and every tag key it names must be one of the three. Anything else - including the team key your ABAC policies read - is rejected. One syntax rule to remember here: if a multivalued string key is compared against a value containing a wildcard or a policy variable, the operator must be StringLike, not StringEquals. The DynamoDB fine-grained pattern has the same overall shape, pinning a caller to their own rows and to a whitelist of attributes:

"Condition": {
  "ForAllValues:StringLike": {
    "dynamodb:LeadingKeys": ["${www.amazon.com:user_id}"]   // variable -> StringLike
  },
  "ForAllValues:StringEquals": {
    "dynamodb:Attributes": ["UserId", "DisplayName", "Email"]
  },
  "StringEqualsIfExists": { "dynamodb:Select": "SPECIFIC_ATTRIBUTES" }
}

The partition-key restriction and the attribute whitelist sit in separate operator blocks because the first carries a policy variable and therefore needs StringLike - the rule from the previous paragraph, in practice. The dynamodb:Select clause is not decoration either: without it a caller can request the whole item projection and bypass the attribute list. Set operators constrain what the request named, so any API shape that lets the caller ask for "everything" has to be closed separately.

Do not reach for set operators on single-valued keys. aws:PrincipalTag/team holds one value per tag key; wrapping it in ForAllValues adds the empty-set behaviour without adding any expressive power.

Global condition keys worth memorising

Global keys are prefixed aws: and are candidates for any request, but "candidate" is not "guaranteed" - availability still depends on the service, the action, and how the call arrived. These are the ones that carry the most weight per line of policy.

Organization identity

aws:PrincipalOrgID is the highest-leverage key in the set. In a resource policy it replaces an unmaintainable list of account IDs with a single value, and it keeps working as accounts are added. aws:PrincipalOrgPaths narrows to an OU subtree and is multivalued. aws:ResourceOrgID points the other way - it describes the organization that owns the resource - which is how you write a data-perimeter deny that stops your principals from writing into someone else's bucket.

aws:SourceArn and aws:SourceAccount - the confused-deputy pair

When an AWS service calls another service on your behalf, the resource policy on the callee sees the service as principal, not you. Without further conditions, anyone in any account who can point that service at your resource gets your service's authority. aws:SourceAccount (the account that owns the triggering resource) and aws:SourceArn (the triggering resource itself) close it:

{ "Effect": "Allow",
  "Principal": { "Service": "s3.amazonaws.com" },
  "Action": "sns:Publish",
  "Resource": "arn:aws:sns:eu-west-1:111122223333:ingest-events",
  "Condition": {
    "StringEquals": { "aws:SourceAccount": "111122223333" },
    "ArnLike":      { "aws:SourceArn": "arn:aws:s3:::landing-zone-*" }
  } }

Use both. aws:SourceAccount alone still trusts every resource in your own account; aws:SourceArn alone is stricter but some services populate it in forms that are awkward to pattern-match, and a few populate only the account. Which of the two a given service supplies is documented per service - check before you rely on one.

Network origin

aws:SourceIp is the public IP the request came from. It is absent when the request arrives over a VPC endpoint, because there is no public source address; the private address appears as aws:VpcSourceIp instead, alongside aws:SourceVpc and aws:SourceVpce. An IP-based guardrail that only knows about aws:SourceIp therefore behaves differently for endpoint traffic than for internet traffic, and the difference is invisible until someone routes through PrivateLink.

aws:SourceIp is also absent when an AWS service makes the call for you - CloudFormation creating an EC2 instance, Athena reading S3. That is what aws:ViaAWSService (Bool, true for service-mediated calls) and aws:CalledVia (the multivalued chain of service principals involved) are for. An IP deny that does not exempt aws:ViaAWSService breaks console-driven and service-driven workflows in ways that are painful to debug.

The rest of the short list

aws:SecureTransport (Bool) - deny anything not over TLS; the standard first statement in an S3 bucket policy. aws:RequestedRegion - region pinning, the backbone of residency guardrails. aws:PrincipalIsAWSService (Bool) - true when an AWS service principal is calling directly, which is how you exempt service-linked behaviour from a broad deny without naming every service. aws:MultiFactorAuthPresent and aws:MultiFactorAuthAge (seconds since authentication). aws:PrincipalArn, aws:PrincipalAccount, aws:PrincipalType, aws:userid, aws:username. aws:CurrentTime and aws:EpochTime for time windows. sts:ExternalId for third-party cross-account trust.

Two formatting gotchas cost real hours. For a role session, aws:PrincipalArn reports the role ARN (arn:aws:iam::111122223333:role/Deploy), not the assumed-role session ARN (arn:aws:sts::111122223333:assumed-role/Deploy/session) - patterns written against the session form never match. And aws:userid for an assumed role is AROAEXAMPLEID:session-name, so any comparison against it needs the unique-id prefix, which changes if the role is deleted and recreated.

Service-specific keys and how to find out what exists

Beyond the global set, every service publishes its own keys under its own prefix: s3:prefix, s3:x-amz-server-side-encryption, s3:ExistingObjectTag/<key>, ec2:InstanceType, ec2:Vpc, kms:ViaService, kms:EncryptionContext:<key>, iam:PermissionsBoundary, iam:PassedToService, iam:PolicyARN. These are usually where the interesting controls live - kms:ViaService restricting a data key to decryption through one service, iam:PassedToService constraining what a PassRole grant can hand an identity to.

The authoritative answer to "what keys can I use here" is the Service Authorization Reference, which publishes three tables per service: actions, resource types, and condition keys. The table that matters is actions, because it lists, per action, which resource types it accepts and which condition keys are available for that action. A key listed on the service's condition-key table is not automatically available on every one of that service's actions.

The corollary is the most common dead policy in AWS: tag conditions on actions that do not support resource-level permissions. List and describe operations frequently act on "Resource": "*" with no resource in the request context, so aws:ResourceTag/... is simply not there. A statement combining ec2:DescribeInstances with a resource-tag condition can never match; the API either works for everything or nothing, and the condition is decoration. Check the actions table before writing a tag condition, not after the ticket comes in.

Tag-based access control and the tag-mutation problem

The AWS realisation of ABAC is a policy variable comparing a principal tag to a resource tag. One statement then covers every team that follows the tagging convention:

{ "Effect": "Allow",
  "Action": ["ec2:StartInstances", "ec2:StopInstances", "ec2:RebootInstances"],
  "Resource": "arn:aws:ec2:*:*:instance/*",
  "Condition": {
    "StringEquals": {
      "aws:ResourceTag/team": "${aws:PrincipalTag/team}"
    }
  } }

Three tag key families do three different jobs, and mixing them up produces policies that look right and do nothing. aws:ResourceTag/<key> reads a tag already on the target resource - the authorization case. aws:RequestTag/<key> reads a tag being applied by this request - it exists only on create and tag operations, and it is how you force new resources to be labelled. aws:TagKeys is the multivalued list of keys the request touches, used with the set operators to whitelist the vocabulary.

The security property of ABAC is only as strong as control over the tags. If a principal can change a tag, they can change their own authorization - the tag has become an ambient credential. Two mutation paths matter. Resource tags: whoever holds ec2:CreateTags, s3:PutObjectTagging, tag:TagResources or the equivalent can re-label a resource into their own scope, so tagging permissions must themselves be conditioned on aws:RequestTag and aws:TagKeys. Principal tags: iam:TagRole, iam:TagUser and unconstrained sts:TagSession are direct privilege escalation, because they rewrite the left-hand side of the comparison. Treat all of them as IAM-write permissions.

Session tags add a third path. A federated session can carry tags supplied by the identity provider, and marking a tag transitive means it survives into every role chained from that session. That is useful for propagating a team attribute across an assume-role chain, and it is exactly why the trust policy - not just the permissions policy - has to constrain which tags a session may set.

Conditions in SCPs and permission boundaries

Conditions behave identically wherever they appear, but the policy type changes what a matching statement does. Identity and resource policies grant; SCPs and permission boundaries only filter. A condition in a granting policy narrows a grant. A condition in a filtering policy decides whether the ceiling lets something through - it never adds access. The general model, including why the ceilings intersect rather than union, is covered in cloud IAM architecture; what follows is the AWS shape.

The workhorse SCP is a conditional deny. Region pinning is the classic:

{ "Effect": "Deny",
  "NotAction": [ "iam:*", "organizations:*", "sts:*",
                 "cloudfront:*", "route53:*", "support:*", "waf:*" ],
  "Resource": "*",
  "Condition": {
    "StringNotEquals": { "aws:RequestedRegion": ["eu-west-1", "eu-central-1"] },
    "ArnNotLike":      { "aws:PrincipalArn": "arn:aws:iam::*:role/OrgBreakGlass" }
  } }

The NotAction list is not optional. Global services present their endpoints in us-east-1, so a region deny without those exclusions blocks IAM, Organizations, Route 53, CloudFront and Support across the whole organization - a memorable way to lock yourself out. The ArnNotLike clause is the carve-out pattern: because the two keys are ANDed, the deny fires only for a non-approved region and a principal that is not the break-glass role.

Two SCP scoping facts change what you can rely on: SCPs do not restrict the organization's management account, and they do not restrict service-linked roles. A guardrail that assumes universal coverage has two documented holes, and the management account is the one auditors ask about.

Permission boundaries take conditions too, and there is a specific key for enforcing them during delegation. Allowing a team lead to create roles is only safe if they cannot create a role more powerful than their own boundary, which means conditioning the iam:CreateRole grant on iam:PermissionsBoundary - plus denying iam:DeleteRolePermissionsBoundary and iam:PutRolePermissionsBoundary outside the approved policy ARN. Related keys in the same family: iam:PassedToService for constraining iam:PassRole, and iam:PolicyARN for restricting which managed policies a delegated admin may attach.

NotAction and NotResource change what a condition guards

NotAction and NotResource are complement operators, and their safety depends entirely on the Effect. In a Deny, NotAction means "deny everything except this list", which is the correct and standard guardrail shape - the region SCP above relies on it. In an Allow, the same element means "allow every action in AWS except this list", which is administrator access with a short exclusion list, including every service launched after the policy was written.

Conditions do not rescue that. A condition scopes the statement it sits in; it does not narrow the action set. An Allow with NotAction: ["iam:*"] and a tidy tag condition still grants every non-IAM action in AWS to any request that satisfies the tag - and since the condition is usually satisfied by the intended users, the blast radius only appears when someone reaches for an unrelated service.

NotResource carries the same asymmetry with an extra edge: it interacts badly with conditions that depend on the resource. If the request's resource is not in the context - a list operation, or an action without resource-level permissions - then aws:ResourceTag is absent, the tag condition follows the missing-key rules from earlier, and a NotResource allow can end up matching far more than the author modelled.

The rule of thumb worth internalising: use NotAction and NotResource in Deny statements and SCPs, and enumerate positively in Allow statements. If an allow genuinely needs the complement form, treat it as an administrative grant during review, whatever the condition block says.

Testing condition logic before it reaches production

Conditions are the part of IAM most likely to be wrong in a way that no one notices, because the failure is usually "quietly permits" rather than "throws an error". Four tools, in the order they pay off.

Policy validation. aws accessanalyzer validate-policy parses a policy document and returns findings graded ERROR, SECURITY_WARNING, WARNING and SUGGESTION. It catches the type mismatches, the condition keys that are not valid for the actions in the statement, and the structurally over-broad patterns. It is fast and non-interactive, so it belongs in the pull-request pipeline for every policy file in your infrastructure repo.

Custom policy checks. Access Analyzer's check-no-new-access compares a proposed policy against the existing one and fails when the change grants access the old one did not; check-access-not-granted asserts that a specific list of actions is never permitted. These are the two checks that turn "someone reviews the diff" into a gate that holds when reviewers are busy.

Simulation. aws iam simulate-principal-policy answers "would this principal be allowed this action on this resource" and lets you supply the context by hand:

aws iam simulate-principal-policy   --policy-source-arn arn:aws:iam::111122223333:role/DataReader   --action-names s3:GetObject   --resource-arns arn:aws:s3:::analytics-prod/reports/q3.parquet   --context-entries       ContextKeyName=aws:SourceIp,ContextKeyType=ip,ContextKeyValues=203.0.113.5       ContextKeyName=aws:MultiFactorAuthPresent,ContextKeyType=boolean,ContextKeyValues=true

Know its limit before you trust it: you are supplying the context, so the simulator cannot tell you whether a real request will carry that key. Every missing-key bug in this article is invisible to a simulation where you helpfully filled the key in. Verify org-level and resource-policy coverage rather than assuming it, and confirm anything guardrail-shaped in a sandbox account or OU before it goes near production.

Evidence from real traffic. CloudTrail records the caller identity, source IP, TLS details and VPC endpoint id for each request, which is how you learn which network keys are actually populated for a workload rather than guessing. Access Analyzer can generate a policy from CloudTrail history for a principal, giving a realistic starting point instead of a wildcard. And when a deny does fire, read the error text: AWS distinguishes an implicit deny from an explicit deny in an identity policy, a resource policy, a permissions boundary, and a service control policy - which tells you which layer to open before you start editing anything.

Finally, keep an eye on iam:GetServiceLastAccessedDetails. Permissions that have never been exercised are the cheapest ones to remove, and removing an action is always safer than trying to fence it in with a condition you have to reason about later.

End-to-end flow

End-to-end: an engineer's role has a policy allowing s3:GetObject on any bucket tagged team=platform if the request has MFA. She authenticates with MFA and requests an object; policy evaluator sees the resource has team=platform tag, MFA context key is true; SCP doesn't block; resource policy allows; access granted. A second engineer without MFA hits the same API; condition fails; access denied. Access Analyzer later flags that another role has unused s3:PutObject on the same bucket; the finding is reviewed and removed. Policy simulator confirms current effective access matches intent.

An AWS Condition block is an AND across operators and keys with an OR only inside a single key's value list - so one statement can never express "this key or that key". The failures that matter are not syntax errors: they are missing keys. A key absent from the request makes positive operators false and negated operators true, ...IfExists makes it true, and ForAllValues is vacuously true on an empty set - each of which flips an Allow open or a Deny shut. Check the Service Authorization Reference for what a given action actually populates, validate every policy in CI, and remember that a simulator you fed the context to can never reproduce the bug where the context was never there.