Initializing Enclave...

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 Condition block using dynamodb:LeadingKeys that 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/UserId is not set on the assumed role session — the tag was never passed via sts:TagSession or is missing from the IdP SAML assertion.
  • Cognito Identity Pool is used but the policy references cognito-identity.amazonaws.com:sub while the actual sub in the token is the Cognito User Pool sub (different values).
  • Hardcoded partition key in the condition doesn't match the value the application actually queries.
  • Case sensitivity: dynamodb:LeadingKeys comparison is exact string match. User-789user-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 or sts:AssumeRoleWithWebIdentity.
  • Null condition on aws:PrincipalTag/UserId: false: Ensures the policy only allows access when the tag is present. Without this, a session without the tag evaluates the condition as false and 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.

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →