How to Fix AccessDenied on DynamoDB Query with Fine-Grained LeadingKeys Condition
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 15–30 mins
TL;DR
- What broke: Your IAM policy has a
Conditionblock usingdynamodb:LeadingKeysthat restricts which partition key values a principal can Query — and the value in the request doesn't satisfy the condition, so DynamoDB hard-denies the call. - How to fix it: Align the condition key variable (e.g.,
${aws:PrincipalTag/UserId}or${cognito-identity.amazonaws.com:sub}) with the actual partition key value being queried, and verify the IAM principal actually carries that attribute at evaluation time. - Shortcut: Use our Client-Side Sandbox below to auto-refactor this — paste your failing policy and Query params and get a corrected policy diff without leaking your ARNs.
The Incident (What Does the Error Mean?)
Raw error from AWS SDK / CLI:
An error occurred (AccessDeniedException) when calling the Query operation:
User: arn:aws:sts::123456789012:assumed-role/AppRole/session-abc
is not authorized to perform: dynamodb:Query
on resource: arn:aws:dynamodb:us-east-1:123456789012:table/UserData
with an explicit deny
because no session policy allows the dynamodb:Query action
and the following policies have an explicit condition that was not met:
Condition: {"dynamodb:LeadingKeys": ["user-789"]}
What is actually happening:
DynamoDB fine-grained access control (FGAC) uses the dynamodb:LeadingKeys condition key to restrict a principal to rows where the partition key equals a specific value — typically the caller's own user ID. When the KeyConditionExpression in the Query specifies a partition key value that does not match what the policy's condition resolves to for that principal, DynamoDB evaluates the condition as false and the result is an explicit deny. This is not a permissions gap — it is the policy working exactly as designed, but the identity binding is broken.
Immediate consequence: Every Query call from this principal against any partition key other than the one bound in the condition fails with AccessDeniedException. In multi-tenant apps this silently breaks data reads for entire user cohorts without any alarm unless you have CloudTrail → CloudWatch metric filters on AccessDenied.
The Attack Vector / Blast Radius
This misconfiguration cuts both ways:
Scenario A — Condition too loose (security hole): If the condition variable is wrong or missing, a low-privilege user can Query any partition key in the table. In a multi-tenant UserData table, that is a full horizontal privilege escalation — user A reads user B's records. No exploit code required; a crafted KeyConditionExpression is sufficient.
Scenario B — Condition too strict or misbound (availability hole, your current state): The condition variable doesn't resolve to the right value at policy evaluation time. Common causes:
aws:PrincipalTag/UserIdis not set on the assumed role session — the tag was never passed viasts:TagSessionor is missing from the IdP SAML assertion.- Cognito Identity Pool is used but the policy references
cognito-identity.amazonaws.com:subwhile the actual sub in the token is the Cognito User Poolsub(different values). - Hardcoded partition key in the condition doesn't match the value the application actually queries.
- Case sensitivity:
dynamodb:LeadingKeyscomparison is exact string match.User-789≠user-789.
Blast radius: In a serverless API backed by Lambda + DynamoDB, if the IAM role used by Lambda has this broken condition, all users hitting that endpoint get 403s. The failure is silent at the DynamoDB layer — API Gateway returns a 502 or your Lambda surfaces an unhandled exception depending on error handling maturity.
How to Fix It (The Solution)
Basic Fix — Verify the Condition Variable Resolves Correctly
Before touching the policy, confirm what value the condition key actually resolves to for the failing principal:
# Check session tags on the assumed role
aws sts get-caller-identity
aws iam list-role-tags --role-name AppRole
# Simulate the policy evaluation
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/AppRole \
--action-names dynamodb:Query \
--resource-arns arn:aws:dynamodb:us-east-1:123456789012:table/UserData \
--context-entries \
ContextKeyName=dynamodb:LeadingKeys,ContextKeyValues=user-789,ContextKeyType=stringList \
ContextKeyName=aws:PrincipalTag/UserId,ContextKeyValues=user-789,ContextKeyType=string
If simulate-principal-policy returns implicitDeny or explicitDeny, the tag is not being passed to the session.
Enterprise Best Practice — Correct IAM Policy with Proper FGAC Binding
The canonical pattern for per-user DynamoDB row isolation using session tags:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowUserScopedDynamoDBQuery",
"Effect": "Allow",
"Action": [
- "dynamodb:*"
+ "dynamodb:Query",
+ "dynamodb:GetItem",
+ "dynamodb:BatchGetItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/UserData",
"Condition": {
"ForAllValues:StringEquals": {
- "dynamodb:LeadingKeys": "user-hardcoded-id"
+ "dynamodb:LeadingKeys": ["${aws:PrincipalTag/UserId}"]
},
+ "StringEquals": {
+ "dynamodb:Select": "SPECIFIC_ATTRIBUTES"
+ },
+ "Null": {
+ "aws:PrincipalTag/UserId": "false"
+ }
}
},
+ {
+ "Sid": "DenyIfTagMissing",
+ "Effect": "Deny",
+ "Action": "dynamodb:*",
+ "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/UserData",
+ "Condition": {
+ "Null": {
+ "aws:PrincipalTag/UserId": "true"
+ }
+ }
+ }
]
}
Key changes explained:
dynamodb:*→ explicit action list: Least privilege. Never grant wildcard on DynamoDB in FGAC policies."user-hardcoded-id"→"${aws:PrincipalTag/UserId}": Dynamic binding. The condition resolves at evaluation time using the session tag injected by your IdP orsts:AssumeRoleWithWebIdentity.Nullcondition onaws:PrincipalTag/UserId: false: Ensures the policy only allows access when the tag is present. Without this, a session without the tag evaluates the condition asfalseand may fall through to a less restrictive allow elsewhere.- Explicit Deny if tag is missing: Belt-and-suspenders. If any other policy grants broader access, this deny wins.
For Cognito Identity Pools specifically:
"Condition": {
"ForAllValues:StringEquals": {
- "dynamodb:LeadingKeys": ["${cognito-identity.amazonaws.com:sub}"]
+ "dynamodb:LeadingKeys": ["${aws:PrincipalTag/UserId}"]
}
}
Map the Cognito Identity ID to a session tag via your token vending machine or use cognito-identity.amazonaws.com:sub only when using Cognito Identity Pool unauthenticated/authenticated role directly — not via a custom role chain.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
1. Checkov — Catch Wildcard Actions on DynamoDB
Add to your Terraform pipeline:
# .checkov.yaml
checks:
- CKV_AWS_111 # IAM policy allows wildcard actions
- CKV_AWS_107 # DynamoDB table not encrypted (bonus)
Checkov rule CKV_AWS_111 will fail any policy with dynamodb:* in a statement that also has a Condition block — exactly the anti-pattern above.
2. OPA / Conftest — Enforce LeadingKeys Condition is Dynamic
# policies/dynamodb_fgac.rego
package dynamodb.fgac
deny[msg] {
stmt := input.Statement[_]
stmt.Effect == "Allow"
action := stmt.Action[_]
startswith(action, "dynamodb:")
condition := stmt.Condition[_]["dynamodb:LeadingKeys"][_]
not startswith(condition, "${")
msg := sprintf(
"Statement '%v' uses a hardcoded LeadingKeys value '%v'. Use a dynamic principal variable.",
[stmt.Sid, condition]
)
}
Run in CI:
conftest test iam_policy.json --policy policies/
3. CloudTrail → CloudWatch Alarm for AccessDenied on DynamoDB
aws logs put-metric-filter \
--log-group-name CloudTrail/DefaultLogGroup \
--filter-name DynamoDBAccessDenied \
--filter-pattern '{ ($.errorCode = "AccessDeniedException") && ($.eventSource = "dynamodb.amazonaws.com") }' \
--metric-transformations \
metricName=DynamoDBAccessDeniedCount,metricNamespace=Security/DynamoDB,metricValue=1
aws cloudwatch put-metric-alarm \
--alarm-name DynamoDB-AccessDenied-Spike \
--metric-name DynamoDBAccessDeniedCount \
--namespace Security/DynamoDB \
--statistic Sum \
--period 300 \
--threshold 10 \
--comparison-operator GreaterThanOrEqualToThreshold \
--evaluation-periods 1 \
--alarm-actions arn:aws:sns:us-east-1:123456789012:SecurityAlerts
This catches both the broken-condition availability failure and any horizontal privilege escalation probing in real time.
4. Terraform — Tag Assertion at Role Assumption
# Enforce that only sessions with UserId tag can assume the role
data "aws_iam_policy_document" "assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity", "sts:TagSession"]
principals {
type = "Federated"
identifiers = [aws_cognito_identity_pool.main.id]
}
condition {
test = "StringLike"
variable = "aws:RequestedRegion"
values = ["us-east-1"]
}
}
}
Pair this with your IdP configuration to always inject UserId as a transitive session tag. If the tag is absent, the DenyIfTagMissing statement in the policy above hard-blocks access before any data is touched.