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.comandcloudtrail.amazonaws.comare cross-account by nature and will be blocked if your deny has no conditions. Useaws:PrincipalAccountoraws:PrincipalOrgIDconditions rather than blanketNotPrincipalblocks 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
Denywins 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_aclwith canned ACLs likelog-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 enableaws_s3_bucket_ownership_controlswithBucketOwnerEnforcedto 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), theaws_s3_bucket_policyresource 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_changesPowerpipe Control:
aws_compliance.control.s3_bucket_policy_restricts_cross_account_permission_changesProwler Check:
s3_bucket_cross_account_accessAWS Security Hub Control:
S3.6
Last reviewed: 2026-03-09