Back to blog
AWSBest PracticesCloud SecurityIdentity & AccessStorage

S3 Cross-Account Access: Auditing and Locking Down Shared Buckets

Learn how to detect, fix, and prevent risky S3 cross-account access grants. Includes CLI remediation, scoped bucket policies, and CI/CD guardrails.

TL;DR

This check flags S3 bucket policies that grant access to principals in other AWS accounts. Cross-account access is sometimes intentional, but unreviewed grants are a common path to data leakage. Audit every external principal, scope the grant down with conditions, and remove anything you cannot justify.

Cross-account access on an S3 bucket is one of those settings that is perfectly legitimate in one context and a serious liability in another. A bucket shared with your data analytics account is fine. A bucket policy that quietly references an account number nobody on the team recognizes is a problem. This check exists to surface those grants so you can decide which is which.


What this check detects

The s3_cross_account_access check inspects each bucket policy and looks for statements that grant access to an IAM principal belonging to a different AWS account than the one that owns the bucket. In practice that means a Principal block referencing an account ID, role ARN, or user ARN that does not match the bucket owner.

A typical flagged statement looks like this:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowExternalAccountRead",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::210987654321:root"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::acme-shared-reports/*"
    }
  ]
}

Here the bucket lives in one account, but the principal 210987654321 is a separate account. Using :root is especially broad because it grants the permission to every IAM identity in that account, not a specific role.

Note: S3 has several overlapping access mechanisms: bucket policies, ACLs, access points, and IAM policies in the consuming account. This check focuses on the bucket policy. A grant can be split across these layers, so a clean bucket policy does not always mean the bucket is private.


Why it matters

The core risk is simple: data leaves your control boundary. Once another account has read access, the owner of that account can copy your objects anywhere, including buckets that are public or that you have no visibility into. You are trusting not just that account but everyone who holds credentials in it.

A few concrete scenarios where this turns into an incident:

  • Stale partner access. A vendor integration was set up two years ago, the contract ended, and the grant was never revoked. The partner account still pulls your data nightly.
  • Attacker-controlled account. If a phishing campaign or supply chain compromise hands an attacker their own AWS account, a single overly broad bucket policy lets them exfiltrate data without ever touching your account's CloudTrail in a way you would notice.
  • The confused deputy problem. Granting to :root without an aws:PrincipalOrgID or aws:SourceAccount condition means any service or role in that account can be tricked into accessing your bucket on an attacker's behalf.
  • Typo'd account ID. An account number that was supposed to be internal but had a digit wrong now points at an account you do not own.

Warning: Cross-account access is invisible in your own account's resource list. The external account does not show up as a user or role you can browse. The only record is the policy itself, which is exactly why drift goes unnoticed for so long.


How to fix it

Start by reviewing the grant rather than ripping it out. The goal is to confirm whether the access is intentional, and if so, to scope it down to the minimum needed.

1. Inspect the current policy

aws s3api get-bucket-policy \
  --bucket acme-shared-reports \
  --query Policy --output text | jq .

Identify every Principal that references an account ID other than your own. Cross-reference those account IDs against your AWS Organizations account list or an internal inventory.

aws organizations list-accounts \
  --query "Accounts[].{Id:Id,Name:Name}" --output table

2. If the access is not needed, remove it

Danger: Removing or replacing a bucket policy affects live access immediately. If a legitimate workload depends on the grant, you will break it. Confirm with the owning team and check CloudTrail for recent GetObject activity from the external account before you delete anything.

If the entire policy exists only for the bad grant, delete it:

aws s3api delete-bucket-policy --bucket acme-shared-reports

More often you want to remove a single statement and keep the rest. Edit the policy JSON locally, drop the offending statement, then reapply:

aws s3api put-bucket-policy \
  --bucket acme-shared-reports \
  --policy file://cleaned-policy.json

3. If the access is legitimate, scope it down

Do not grant to :root. Name the specific role that needs access, and add conditions that constrain it. Here is a tightened version of the earlier example:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowPartnerReadScoped",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::210987654321:role/data-sync-reader"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::acme-shared-reports/exports/*",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalOrgID": "o-abc123xyz"
        }
      }
    }
  ]
}

Key changes: a named role instead of :root, a prefix-scoped resource instead of the whole bucket, and an aws:PrincipalOrgID condition so only principals inside your organization can use the grant.

Tip: For sharing within an AWS Organization, prefer aws:PrincipalOrgID over hardcoded account IDs. New accounts joining the org are covered automatically, and you avoid the maintenance burden of updating policies every time the structure changes.

4. Consider S3 Access Points for complex sharing

If a bucket is shared with many accounts or teams, a tangle of statements in one bucket policy becomes hard to reason about. S3 Access Points let you attach a separate policy per consumer, each with its own network and identity controls.

aws s3control create-access-point \
  --account-id 123456789012 \
  --bucket acme-shared-reports \
  --name partner-reader \
  --policy file://access-point-policy.json

How to prevent it from happening again

Manual review does not scale. The grant that bites you is the one added at 2am during an incident and never revisited. Push the controls left into your pipeline and your account guardrails.

Block public and external access with SCPs

An organization-wide Service Control Policy can deny any bucket policy change that opens access outside your org. Combine that with AWS Organizations and you stop the problem at the account level rather than per bucket.

Gate policy changes in CI/CD

If your buckets are defined in Terraform or CloudFormation, scan the planned policy before it applies. A simple check that rejects any Principal account ID not on an allowlist catches the typo and the rogue grant alike.

# Example: fail the build if a non-allowlisted account appears in a plan
ALLOWED="123456789012 210987654321"
tofu show -json plan.out \
  | jq -r '.. | .Principal? | objects | .AWS // empty' \
  | grep -oE '[0-9]{12}' | sort -u \
  | while read acct; do
      grep -qw "$acct" <<< "$ALLOWED" || { echo "Unapproved account: $acct"; exit 1; }
    done

Policy-as-code tools like OPA, Conftest, or Checkov fit the same slot and give you reusable rules across every stack.

Tip: Enable S3 Block Public Access at the account level and turn on IAM Access Analyzer for external access findings. Access Analyzer continuously evaluates bucket policies and tells you which resources are reachable from outside your account, which pairs well with this check as a second line of defense.

Define cross-account sharing in code, not the console

Every legitimate grant should live in version control with a commit message that explains who needs it and why. That turns the question "is this account supposed to be here?" from an archaeology project into a git blame.


Best practices

  • Never use :root for cross-account grants. Always name the specific role or user that needs access.
  • Scope to a prefix. Grant access to bucket/exports/*, not the entire bucket, unless the consumer genuinely needs all of it.
  • Add an org or account condition. Use aws:PrincipalOrgID for internal sharing and aws:SourceAccount where it applies to defend against the confused deputy problem.
  • Grant the minimum actions. Read sharing rarely needs s3:PutObject or s3:DeleteObject. Split read and write into separate, justified statements.
  • Review grants on a schedule. Cross-account access decays into stale access. Put a quarterly review on the calendar and tie each grant to a ticket or contract.
  • Log and alert on policy changes. A CloudTrail alarm on PutBucketPolicy gives you near-real-time notice when someone modifies a sharing rule.

Cross-account access is not a vulnerability by itself. The vulnerability is granting it without a record of why, then forgetting it exists. Treat every external principal as a relationship you have to maintain, not a setting you flip once.

Run this check across your accounts, build an inventory of who has access to what, and fold the legitimate grants into code. The ones left over after that exercise are usually the ones worth worrying about.