Initializing Enclave...

How to Fix AWS IAM 'Access Denied' Caused by Missing aws:SourceVpce Condition Key

Threat/Impact Level: CRITICAL | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins

TL;DR

  • What broke: Your S3 bucket policy, API Gateway resource policy, or SQS queue policy has no aws:SourceVpce condition, meaning any IAM principal with credentials can hit the resource from the open internet — or your VPC-locked app is getting Access Denied because the condition exists but references the wrong VPCE ID.
  • How to fix it: Add a Deny statement with StringNotEquals: { "aws:SourceVpce": "vpce-xxxxxxxx" } to enforce that all traffic must originate from your designated VPC Endpoint.
  • Shortcut: Use our Client-Side Sandbox above to auto-refactor this — paste your policy, get the corrected version with the condition injected, zero data leaves your browser.

The Incident (What does the error mean?)

The raw error from CloudTrail or your application logs looks like this:

{
  "errorCode": "AccessDenied",
  "errorMessage": "User: arn:aws:iam::123456789012:role/app-role is not authorized to perform: s3:GetObject on resource: arn:aws:s3:::my-private-bucket/config.json with an explicit deny in a resource-based policy"
}

Or the inverse — no error, but your security team's audit flags that the bucket is reachable from outside the VPC. Both are the same root cause: the aws:SourceVpce condition is either absent, evaluating to null, or referencing a stale VPCE ID.

When AWS evaluates a condition key that doesn't exist in the request context (e.g., a request arriving over the public S3 endpoint instead of a VPC Endpoint), aws:SourceVpce is absent from the request context entirely. A StringEquals allow condition silently fails. A StringNotEquals deny condition triggers. This is why the behavior flips depending on how you've structured the policy.


The Attack Vector / Blast Radius

This is a network perimeter bypass. The entire premise of a VPC Endpoint policy is to ensure your private resources are only reachable from within your network boundary. Without aws:SourceVpce enforced on the resource policy:

  • Credential theft = full data exfiltration. A stolen IAM access key allows an attacker to aws s3 cp s3://my-private-bucket/ . --recursive from their laptop. Your VPC, Security Groups, and NACLs are completely irrelevant at this point — S3, SQS, and API Gateway are public AWS endpoints.
  • Blast radius on S3: Every object in the bucket. Compliance data, PII, application secrets stored as files.
  • Blast radius on API Gateway: Full API invocation from any IP. If your API has write operations, this is RCE-adjacent depending on backend logic.
  • Blast radius on SQS/SNS: Message injection or queue draining — an attacker can poison your job queue or read every message in flight.
  • The aws:SourceIp trap: Many engineers add aws:SourceIp as a fix. This does not work for VPC traffic because traffic from within a VPC to an AWS service traverses the AWS network, not a predictable public IP. Only aws:SourceVpce or aws:SourceVpc reliably locks resources to your VPC boundary.

How to Fix It (The Solution)

Basic Fix — Add the Deny Statement to Your Resource Policy

The safest pattern is an explicit Deny for any request not sourced from your VPCE. A missing condition key evaluates StringNotEquals as true, so the deny fires correctly for public-internet requests.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowRoleAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/app-role"
      },
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::my-private-bucket/*"
    },
+   {
+     "Sid": "DenyNonVPCEAccess",
+     "Effect": "Deny",
+     "Principal": "*",
+     "Action": "s3:*",
+     "Resource": [
+       "arn:aws:s3:::my-private-bucket",
+       "arn:aws:s3:::my-private-bucket/*"
+     ],
+     "Condition": {
+       "StringNotEquals": {
+         "aws:SourceVpce": "vpce-0a1b2c3d4e5f67890"
+       }
+     }
+   }
  ]
}

Enterprise Best Practice — Multi-VPCE + VPC-Level Fallback

In multi-account or multi-region architectures, you'll have more than one VPCE. Lock to the VPC ID as a secondary control and enumerate all valid VPCEs.

-   "Condition": {
-     "StringNotEquals": {
-       "aws:SourceVpce": "vpce-0a1b2c3d4e5f67890"
-     }
-   }
+   "Condition": {
+     "StringNotEquals": {
+       "aws:SourceVpce": [
+         "vpce-0a1b2c3d4e5f67890",
+         "vpce-0f9e8d7c6b5a43210"
+       ]
+     },
+     "StringNotEqualsIfExists": {
+       "aws:SourceVpc": "vpc-0123456789abcdef0"
+     }
+   }

Critical implementation notes:

  • Apply this Deny to both the bucket/resource ARN and the ARN with /* wildcard — missing either leaves a gap.
  • The VPCE must have a VPC Endpoint Policy that also restricts which principals and actions are allowed. Defense in depth: resource policy + endpoint policy.
  • For API Gateway, the condition goes in the aws:sourceVpc field on the resource policy and you set endpointConfiguration.type to PRIVATEaws:SourceVpce alone is insufficient without also making the API private.
  • Validate your VPCE ID against aws ec2 describe-vpc-endpoints --query 'VpcEndpoints[*].{ID:VpcEndpointId,Service:ServiceName}' — stale IDs after infrastructure rebuilds are the #1 cause of this error in production.

💡 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 missing VPCE conditions at plan time

Checkov rule CKV_AWS_70 checks S3 bucket policies for VPC-restricted access. Wire it into your pipeline:

checkov -d ./terraform --check CKV_AWS_70 --compact

For custom policies not covered by built-in rules, write a custom Checkov check or use checkov --external-checks-dir ./custom_checks.

2. OPA/Conftest — Enforce VPCE conditions on any resource policy

# policy/vpce_required.rego
package aws.iam

deny[msg] {
  stmt := input.Statement[_]
  stmt.Effect == "Allow"
  stmt.Principal == "*"
  not has_vpce_condition(stmt)
  msg := sprintf("Statement '%v' allows public principal without aws:SourceVpce deny condition", [stmt.Sid])
}

has_vpce_condition(stmt) {
  stmt.Condition.StringNotEquals["aws:SourceVpce"]
}

Run in CI: conftest test policy.json --policy policy/

3. AWS Config Rule — Continuous compliance in production

Deploy the managed rule s3-bucket-policy-not-more-permissive and supplement with a custom Lambda-backed Config rule that parses bucket policies for the presence of a StringNotEquals block on aws:SourceVpce. Alert to your Security Hub on violation.

4. Terraform — Codify the condition so it can never be omitted

Create a Terraform module for private bucket creation that hardcodes the deny statement, accepting vpce_ids as a required variable with validation:

variable "vpce_ids" {
  type        = list(string)
  description = "List of VPC Endpoint IDs permitted to access this bucket."
  validation {
    condition     = length(var.vpce_ids) > 0
    error_message = "At least one VPCE ID must be specified. Public bucket access is not permitted."
  }
}

If vpce_ids is empty, terraform plan fails. The misconfiguration never reaches AWS.

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →