Skip to content

S3 buckets should restrict cross-account permissions

An S3 bucket policy that allows cross-account principals to change permissions creates a privilege escalation path. An external account could grant itself broader access, exfiltrate data, or silently remove protections like encryption requirements. This is a documented attack path in cloud penetration testing engagements.

Explicit deny statements override any allow, including those from the bucket-owning account's IAM policies. Without them, you're relying on the absence of grants, which is fragile as policies evolve and teams add cross-account sharing over time.

Retrofit consideration

Existing buckets may have permissive policies or rely on ACLs for cross-account sharing. Adding explicit deny statements can break legitimate workflows like CloudTrail log delivery or replication. Scope conditions carefully using aws:PrincipalAccount or aws:PrincipalOrgID to exempt authorized service principals before rolling out.

Implementation

Choose the approach that matches how you manage Terraform.

Use the compliance.tf module to enforce this control by default. See get started with compliance.tf.

module "s3_bucket" {
  source  = "pcidss.compliance.tf/terraform-aws-modules/s3-bucket/aws"
  version = ">=5.0.0"

  bucket = "abc123"
}

module "s3_bucket" {
  source  = "nistcsf.compliance.tf/terraform-aws-modules/s3-bucket/aws"
  version = ">=5.0.0"

  bucket = "abc123"
}

module "s3_bucket" {
  source  = "acscessentialeight.compliance.tf/terraform-aws-modules/s3-bucket/aws"
  version = ">=5.0.0"

  bucket = "abc123"
}

module "s3_bucket" {
  source  = "nistcsfv11.compliance.tf/terraform-aws-modules/s3-bucket/aws"
  version = ">=5.0.0"

  bucket = "abc123"
}

If you use terraform-aws-modules/s3-bucket/aws, set the right module inputs for this control. You can later migrate to the compliance.tf module with minimal changes because it is compatible by design.

module "s3_bucket" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = ">=5.0.0"

  bucket = "abc123"
}

Use AWS provider resources directly. See docs for the resources involved: aws_s3_bucket_policy.

resource "aws_s3_bucket" "this" {
  bucket        = "pofix-abc123"
  force_destroy = true
}

resource "aws_s3_bucket_policy" "this" {
  bucket = "example-bucket-abc123"
  policy = jsonencode({ Version = "2012-10-17", Statement = [{ Effect = "Deny", Principal = "*", Action = "s3:*", Resource = ["arn:aws:s3:::example-bucket-abc123", "arn:aws:s3:::example-bucket-abc123/*"], Condition = { Bool = { "aws:SecureTransport" = "false" } } }] })
}

What this control checks

This control validates that every S3 bucket has an aws_s3_bucket_policy resource whose policy document contains explicit Deny statements targeting cross-account principals for permission-altering actions. The actions to deny include s3:PutBucketPolicy, s3:DeleteBucketPolicy, s3:PutBucketAcl, and s3:PutObjectAcl. The deny statement needs a Condition block with StringNotEquals on aws:PrincipalAccount (or aws:PrincipalOrgID for org-scoped deployments) to exempt the owning account while blocking all others.

A bucket with no bucket policy, or a policy that omits these deny statements, fails. A bucket using aws_s3_bucket_public_access_block alone also fails because public access blocks don't restrict authenticated cross-account access. The policy argument must contain the deny logic as a valid JSON policy document, typically built with aws_iam_policy_document data sources.

Common pitfalls

  • Overly broad deny breaks legitimate cross-account access

    Service principals like logging.s3.amazonaws.com and cloudtrail.amazonaws.com are cross-account by nature and will be blocked if your deny has no conditions. Use aws:PrincipalAccount or aws:PrincipalOrgID conditions rather than blanket NotPrincipal blocks to keep AWS service integrations working.

  • Policy ordering confusion with multiple statements

    Engineers sometimes assume statement order in the policy JSON matters. It doesn't. An explicit Deny wins regardless of where it appears in the array. Focus on getting the actions and conditions right, not placement.

  • Using ACL-based sharing instead of bucket policy

    Legacy configurations may grant cross-account access via aws_s3_bucket_acl with canned ACLs like log-delivery-write. ACL-based access is harder to reason about, especially with mixed object ownership, though explicit bucket policy denies still apply to matching requests. The right fix is to migrate to policy-based access and enable aws_s3_bucket_ownership_controls with BucketOwnerEnforced to disable ACLs entirely.

  • Terraform policy document drift

    Get this wrong and the live bucket can be non-compliant for the entire window between Terraform applies. If the bucket policy is modified outside Terraform (via console or aws s3api put-bucket-policy), the aws_s3_bucket_policy resource shows drift on the next plan, but Terraform doesn't alert you in real time. Use AWS Config for continuous detection.

Audit evidence

Auditors expect the actual bucket policy JSON for each in-scope bucket, confirming explicit Deny statements covering the permission-modification actions. AWS Config rule results (custom rule or conformance pack) showing compliant status across all buckets provide continuous evidence. S3 console screenshots of the policy tab work as point-in-time evidence but should be supplemented with automated scan output.

CloudTrail logs showing PutBucketPolicy events confirm when policies were applied and by whom, establishing a change history. CSPM tool findings that flag buckets missing cross-account deny statements provide an independent verification layer.

Framework-specific interpretation

PCI DSS v4.0: Requirement 7 limits access to cardholder data and system components to those with a business need. For S3 buckets in PCI scope, explicit deny statements demonstrate that access controls can't be weakened by an external account, which is what 7.2.x assessors look for when reviewing access assignment controls.

NIST Cybersecurity Framework v2.0: PR.AA covers identity management and access control. Blocking cross-account principals from modifying bucket policies or ACLs is a direct expression of least privilege at the storage layer. This also touches the Detect function: by making unauthorized cross-account policy changes explicitly impossible rather than merely unmonitored, you eliminate a class of permissions drift that detection-only approaches can miss.

Tool mappings

Use these identifiers to cross-reference this control across tools, reports, and evidence.

  • Compliance.tf Control: s3_bucket_policy_restricts_cross_account_permission_changes

  • Powerpipe Control: aws_compliance.control.s3_bucket_policy_restricts_cross_account_permission_changes

  • Prowler Check: s3_bucket_cross_account_access

  • AWS Security Hub Control: S3.6

Last reviewed: 2026-03-09