Initializing Enclave...

How to Fix AWS Organizations SCP Explicit Deny Blocking s3:PutObject

Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 15–30 mins (SCP propagation included)

TL;DR

  • What broke: An SCP attached at the AWS Organizations OU or root level contains an explicit Deny on s3:PutObject, which AWS policy evaluation resolves before any identity-based Allow — your IAM role's permissions are irrelevant.
  • How to fix it: Locate the offending SCP statement via AWS Organizations console or aws organizations list-policies, narrow the Deny scope using Condition keys or a NotPrincipal/StringNotLike guard, and re-attach the corrected policy.
  • Fast path: Use our Client-Side Sandbox above to paste your SCP JSON and IAM policy — it auto-identifies the conflicting statement and generates the refactored diff without sending your ARNs anywhere.

The Incident (What Does the Error Mean?)

The raw error returned by the S3 API or visible in CloudTrail:

An error occurred (AccessDenied) when calling the PutObject operation:
  User: arn:aws:sts::123456789012:assumed-role/MyAppRole/session
  is not authorized to perform: s3:PutObject on resource:
  arn:aws:s3:::my-critical-bucket/uploads/file.csv
  with an explicit deny in a service control policy

The phrase "explicit deny in a service control policy" is the critical signal. This is not a missing Allow. AWS policy evaluation order is deterministic:

  1. Explicit Deny (SCP, resource policy, session policy) → STOP. Access denied.
  2. Explicit Allow
  3. Implicit Deny (default)

No matter how permissive the IAM role policy is — even s3:* on * — an SCP explicit Deny wins unconditionally. Your application writes are silently failing, uploads are dropping, and any dependent pipeline (ETL, backup, artifact push) is now broken.


The Attack Vector / Blast Radius

This is a misconfigured guardrail, not a breach — but the blast radius is severe in both directions:

Operational impact: Every principal in every account under the affected OU inherits this deny. If the SCP is attached at the Root, all accounts in the organization are blocked. A single overly-broad SCP statement can silently break:

  • Application data ingestion pipelines
  • CloudTrail log delivery to S3
  • Config rule snapshots
  • Cross-account replication jobs
  • CI/CD artifact uploads (CodePipeline, GitHub Actions OIDC roles)

Security regression risk (the dangerous fix): The instinct under production pressure is to add a blanket Allow SCP or remove the deny entirely. Do not do this. The original SCP likely exists to prevent data exfiltration to uncontrolled buckets, enforce encryption-at-rest requirements (s3:x-amz-server-side-encryption), or block writes to buckets outside approved regions. Removing it without a scoped replacement creates a real exfiltration vector — any compromised role in the OU can now write to attacker-controlled S3 buckets.

The correct fix preserves the security intent while unblocking the legitimate principal.


How to Fix It (The Solution)

Step 1 — Identify the offending SCP

# List all SCPs in the organization
aws organizations list-policies --filter SERVICE_CONTROL_POLICY \
  --query 'Policies[*].{Id:Id,Name:Name}' --output table

# Find which SCPs are attached to the affected account's OU chain
aws organizations list-policies-for-target \
  --target-id ou-xxxx-yyyyyyyy \
  --filter SERVICE_CONTROL_POLICY \
  --query 'Policies[*].{Id:Id,Name:Name}'

# Pull the full document for each candidate SCP
aws organizations describe-policy --policy-id p-xxxxxxxxxx \
  --query 'Policy.Content' --output text | python3 -m json.tool

Search the output for "Effect": "Deny" combined with s3:PutObject or s3:*.


Basic Fix — Add a StringNotLike Condition Guard

The most common pattern: the SCP denies S3 writes to buckets outside an approved list. Fix it by scoping the deny to only fire when the bucket is not in your approved set.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyS3PutToUncontrolledBuckets",
      "Effect": "Deny",
      "Action": [
-       "s3:*"
+       "s3:PutObject",
+       "s3:PutObjectAcl"
      ],
      "Resource": "*",
      "Condition": {
-       "StringNotEquals": {
-         "s3:ResourceAccount": "123456789012"
-       }
+       "StringNotLike": {
+         "s3:ResourceAccount": [
+           "123456789012",
+           "987654321098"
+         ]
+       }
      }
    }
  ]
}

Why this works: The Deny now only triggers when the target bucket belongs to an account outside your approved list. Writes to your own account's buckets pass through to IAM evaluation.


Enterprise Best Practice — Use aws:PrincipalArn NotLike to Exempt Specific Roles

For break-glass scenarios or service roles that legitimately need cross-account write access:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyUnencryptedS3Writes",
      "Effect": "Deny",
      "Action": "s3:PutObject",
      "Resource": "*",
      "Condition": {
+       "StringNotLike": {
+         "aws:PrincipalArn": [
+           "arn:aws:iam::*:role/DataPipelineRole",
+           "arn:aws:iam::*:role/BackupServiceRole"
+         ]
+       },
        "StringNotEquals": {
          "s3:x-amz-server-side-encryption": [
            "aws:kms",
            "AES256"
          ]
        }
      }
    }
  ]
}

⚠️ Critical: aws:PrincipalArn in SCPs uses the resolved ARN of the assumed role, not the role session ARN. Use wildcards (*) for the account segment when the role exists in multiple member accounts. Test with IAM Policy Simulator before attaching to production OUs.

After updating the SCP:

aws organizations update-policy \
  --policy-id p-xxxxxxxxxx \
  --content file://updated-scp.json

SCP propagation typically completes within 15–30 seconds but can take up to 5 minutes in large organizations.


💡 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. Validate SCPs in Pull Requests with OPA

Write an OPA policy that rejects any SCP PR that introduces a bare Deny on s3:* or s3:PutObject without a scoping Condition block:

# opa/scp_deny_guard.rego
package scp.validation

violation[msg] {
  stmt := input.Statement[_]
  stmt.Effect == "Deny"
  action := stmt.Action[_]
  contains(action, "s3")
  not stmt.Condition
  msg := sprintf("SCP Statement '%v' has an unconditional Deny on S3. Add a scoping Condition.", [stmt.Sid])
}
# In your CI pipeline
opa eval --data opa/scp_deny_guard.rego \
         --input proposed-scp.json \
         "data.scp.validation.violation" --fail-defined

2. Checkov for Terraform aws_organizations_policy Resources

checkov -d ./terraform/org-policies \
  --check CKV_AWS_110,CKV_AWS_111 \
  --compact

Add custom Checkov checks for your organization's approved condition key allowlist.

3. Pre-merge SCP Simulation with aws iam simulate-principal-policy

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/DataPipelineRole \
  --action-names s3:PutObject \
  --resource-arns arn:aws:s3:::my-critical-bucket/* \
  --query 'EvaluationResults[*].{Action:EvalActionName,Decision:EvalDecision}'

This does not simulate SCPs directly (it only evaluates identity policies), but combined with manual SCP review and OPA, it closes the gap. For full SCP simulation, use the IAM Policy Simulator in the AWS Console under the Organizations context, or the access-analyzer API with ValidatePolicy.

4. AWS Config Rule for Continuous Drift Detection

Enable the managed rule ORGANIZATIONS_POLICY_IN_USE and write a custom Lambda-backed Config rule that alerts on any SCP modification touching s3:PutObject without a required condition key (s3:ResourceAccount or aws:RequestedRegion).

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →