Most broken guardrails don't fail because someone used the wrong key. They fail because the key wasn't in the request context, and the operator treated "missing" in a way nobody expected. A deny that should block outsiders also blocks CloudTrail writing to your log bucket. An IP allow list stops working the day traffic moves to a VPC endpoint. This sheet lists the keys, when each one is present, and the patterns that hold up in production.
Missing-key behaviour: the rules to memorise
| Condition form | Key missing from the request | Consequence |
|---|---|---|
StringEquals, Bool, IpAddress, ... | Evaluates false | An Allow doesn't apply; a Deny doesn't apply |
StringNotEquals, NotIpAddress, ArnNotLike, ... | Evaluates true | A Deny applies: this is what locks out service principals |
...IfExists (e.g. StringNotEqualsIfExists) | Evaluates true | Use it when "absent" should count as "OK" for an Allow, or "skip" for a negated Deny |
Null: {"key": "true"} | True if the key is absent | Use it to require a key explicitly |
ForAllValues: | True (empty set) | Allows that rely on it pass when the key is missing; pair with Null: false |
ForAnyValue: | False | Safe default for Deny statements on multivalued keys |
Warning
Never write
"ForAllValues:StringEquals": {"aws:TagKeys": [...]}in an Allow on its own. A request with no tags passes. Add"Null": {"aws:TagKeys": "false"}if tags are required.
Organisation and account boundary keys
| Key | Present when | Typical use |
|---|---|---|
aws:PrincipalOrgID | Caller is an IAM principal in an account in an organisation | Resource policy: only principals from my org |
aws:PrincipalOrgPaths | Same, multivalued OU paths | Narrow to an OU: ForAnyValue:StringLike o-abc/r-xyz/ou-prod* |
aws:PrincipalAccount | Always for IAM principals | Exact account checks |
aws:ResourceOrgID, aws:ResourceAccount, aws:ResourceOrgPaths | Resource owner is known to the service | Identity/SCP side: only touch resources in my org |
aws:SourceOrgID, aws:SourceAccount, aws:SourceArn | A service principal acts on behalf of a resource | Confused-deputy protection |
aws:PrincipalIsAWSService | Always: true for service principals such as cloudtrail.amazonaws.com | Exempt service principals from org checks |
Resource perimeter on a bucket, with a service-principal exemption so log delivery keeps working:
{
"Sid": "DenyOutsideOrg",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::corp-logs", "arn:aws:s3:::corp-logs/*"],
"Condition": {
"StringNotEqualsIfExists": { "aws:PrincipalOrgID": "o-a1b2c3d4e5" },
"BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
}
}
Both conditions must be true for the deny to apply: the caller isn't in the org and isn't an AWS service principal. Service principals are then held in check by aws:SourceOrgID or aws:SourceAccount in the Allow that grants them access.
Confused deputy: trust and resource policies for service principals
Any policy that grants a service principal access should pin which resource it acts for:
{
"Effect": "Allow",
"Principal": { "Service": "sns.amazonaws.com" },
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:eu-west-1:111122223333:orders",
"Condition": {
"ArnEquals": { "aws:SourceArn": "arn:aws:sns:eu-west-1:111122223333:orders-topic" }
}
}
Use aws:SourceArn when you know the exact resource. Use aws:SourceAccount or aws:SourceOrgID when the resource ARN isn't known in advance or varies. Not every service sets these keys for every integration; check the service's documentation before relying on them.
Network perimeter keys
| Key | Present when | Trap |
|---|---|---|
aws:SourceIp | Request arrives over the public endpoint | Absent when the request comes through a VPC endpoint. For calls an AWS service makes on your behalf, it holds the service's address, not yours |
aws:SourceVpc, aws:SourceVpce | Request arrives through a VPC endpoint | Absent on public endpoint traffic |
aws:VpcSourceIp | Through a VPC endpoint; the client's private IP | Private ranges overlap across VPCs; combine with aws:SourceVpc |
aws:ViaAWSService | True when a service (e.g. CloudFormation, Athena, KMS via S3) calls onward using your credentials | Network conditions must exempt these calls or the service breaks |
aws:EC2InstanceSourceVPC, aws:EC2InstanceSourcePrivateIPv4 | Credentials came from an EC2 instance role | Lets you pin instance credentials to their VPC even if they're exfiltrated |
Deny requests from outside corporate IPs or your VPCs, without breaking AWS services or service-to-service calls:
{
"Sid": "DenyOutsideNetwork",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"NotIpAddressIfExists": { "aws:SourceIp": ["203.0.113.0/24"] },
"StringNotEqualsIfExists": { "aws:SourceVpc": ["vpc-0aaa111", "vpc-0bbb222"] },
"BoolIfExists": {
"aws:ViaAWSService": "false",
"aws:PrincipalIsAWSService": "false"
}
}
}
Caution
Applied as an SCP or identity policy to humans and CI roles, a network deny like this can block console sessions from new office ranges or VPN egress addresses. It also blocks pipelines on hosted runners, whose IPs change. Roll it out with an exemption tag such as
aws:PrincipalTag/perimeter-exempt, and test from every access path first.
Session and credential keys
| Key | Notes |
|---|---|
aws:PrincipalArn | For role sessions, the role ARN, not the session ARN. This is the key to use for "this role only" |
aws:userid | Role sessions: AROA...:session-name; useful for per-session matching |
aws:SourceIdentity | Set at assume-role time and persists through role chaining; good for tying actions to a human |
aws:MultiFactorAuthPresent | Absent for long-term access keys. Use BoolIfExists ... false in a Deny to catch both no-MFA cases |
aws:MultiFactorAuthAge | Seconds since MFA; absent without MFA |
aws:TokenIssueTime | Use to revoke sessions issued before a time (the console's "revoke active sessions" does this) |
aws:PrincipalTag/<key> | Tags on the role/user or session tags. The basis of ABAC |
aws:RequestedRegion | Global services (IAM, Organizations, Route 53, CloudFront, STS global endpoint) report us-east-1; exempt them with NotAction in Region-deny SCPs |
aws:SecureTransport | Deny false on S3 bucket policies to force TLS |
aws:CalledVia, aws:CalledViaFirst, aws:CalledViaLast | Only populated for a limited set of services that forward requests; check the list before relying on it |
Require MFA for destructive IAM actions, covering access keys too:
{
"Effect": "Deny",
"Action": ["iam:DeleteRole", "iam:DeleteUser", "iam:PutRolePolicy", "iam:AttachRolePolicy"],
"Resource": "*",
"Condition": { "BoolIfExists": { "aws:MultiFactorAuthPresent": "false" } }
}
Test conditions before rollout
Simulate a request with specific context values:
aws iam simulate-custom-policy \
--policy-input-list file://perimeter.json \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::corp-logs/2026/11/13/file.gz \
--context-entries \
ContextKeyName=aws:SourceIp,ContextKeyValues=198.51.100.7,ContextKeyType=ip \
ContextKeyName=aws:ViaAWSService,ContextKeyValues=false,ContextKeyType=boolean \
--query 'EvaluationResults[].{action:EvalActionName,decision:EvalDecision}'
To see which context keys a policy references, and so which ones you must supply:
aws iam get-context-keys-for-custom-policy --policy-input-list file://perimeter.json
Lint with Access Analyzer. It catches unsupported keys for an action, operator/type mismatches, and ForAllValues used on single-valued keys:
aws accessanalyzer validate-policy --policy-type RESOURCE_POLICY \
--validated-resource-type AWS::S3::Bucket \
--policy-document file://bucket-policy.json \
--query 'findings[].{type:findingType,issue:issueCode,detail:findingDetails}' --output table
Then confirm in production with CloudTrail, which shows real denials and their errorMessage:
aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventSource,AttributeValue=s3.amazonaws.com \
--max-results 50 --query 'Events[].CloudTrailEvent' --output text \
| jq -r 'fromjson | select(.errorCode=="AccessDenied") | [.eventTime,.userIdentity.arn,.sourceIPAddress,.errorMessage] | @tsv'
S3 data events appear here only if your trail or event data store records them.
Prevent it next time
- Keep perimeter statements in one reviewed module, such as an SCP/RCP library or a Terraform module, instead of copy-pasting them into bucket policies.
- Add the service-principal and
aws:ViaAWSServiceexemptions by default. Remove them only with a written reason. - Run
validate-policyin CI on every policy change, and fail the build onERRORandSECURITY_WARNINGfindings. - Before enforcing a network or org deny, deploy it with a non-matching test principal and review CloudTrail
AccessDeniedevents for a week.