In modern cloud engineering, managing multi-account AWS topologies is the standard. Organizations split environments into distinct AWS accounts (e.g. Development, Production, Security, and Log Archive) using AWS Organizations. However, isolated accounts frequently need to share resources—specifically, reading or writing objects in Amazon S3.

In this guide, we'll dive deep into S3 cross-account access. We will explore resource-based policies vs identity-based role assumptions, solve the classic S3 object ownership issue, and deploy the entire topology using Terraform.

Design Choice: Should you use a Bucket Policy or an IAM Role for cross-account access? We will break down when to use each approach based on production requirements.

The Two Architectural Approaches

Approach A: S3 Bucket Policy (Resource-Based Access)

In this model, Account B (which owns the destination bucket) applies a bucket policy that grants write permissions directly to the IAM User or Role in Account A (the source account).

  • Pros: Straightforward configuration; no role-assumption latency; uses the caller's active security session.
  • Cons: By default, objects uploaded by Account A are owned by Account A, meaning the bucket owner (Account B) cannot read them without explicit ownership configuration.

Approach B: IAM Role Assumption (Identity-Based Access)

Here, Account B creates an IAM Role that has write access to the bucket. The user or service in Account A performs an sts:AssumeRole call to act as the role in Account B.

  • Pros: The assumed role belongs to Account B, so any uploaded objects are automatically owned by the bucket owner. Excellent for complex multi-account pipelines.
  • Cons: Requires credential rotation handling or active API calls to assume the role before execution.

Scenario Definition

Let's implement a production-grade Bucket Policy (Approach A) access path. We'll secure it by enforcing S3 Object Ownership settings so Account B inherits ownership of all incoming files.

  • Source Account A: AWS Account ID 111111111111
  • Destination Account B: AWS Account ID 222222222222
  • Bucket Name: company-data-archive-222222222222

Step 1: Configure the S3 Bucket Policy (Account B)

Account B must configure the bucket policy to allow Account A's IAM principal to upload files. We must also enforce that Account A grants full control over the uploaded files to Account B using the bucket-owner-full-control ACL.

bucket_policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowAccountAToUploadObjects",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111111111111:role/data-uploader-role"
      },
      "Action": [
        "s3:PutObject",
        "s3:PutObjectAcl"
      ],
      "Resource": "arn:aws:s3:::company-data-archive-222222222222/*",
      "Condition": {
        "StringEquals": {
          "s3:x-amz-acl": "bucket-owner-full-control"
        }
      }
    }
  ]
}

Step 2: Attach the IAM Policy (Account A)

Now, we grant permission to Account A's IAM role (data-uploader-role) to write objects to the bucket in Account B.

iam_policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowWriteToAccountBBucket",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:PutObjectAcl"
      ],
      "Resource": "arn:aws:s3:::company-data-archive-222222222222/*"
    }
  ]
}

The Crucial Step: S3 Object Ownership Setting

Historically, even with the policies above, objects uploaded by Account A were owned by Account A. If Account B attempted to read them, it received an Access Denied error.

To fix this permanently without writing custom code, enable **S3 Object Ownership** on Account B's bucket. Set it to Bucket Owner Preferred or, ideally, Bucket Owner Enforced (which disables ACLs entirely and automatically gives Account B ownership of all uploaded objects).

Codifying in Terraform

Here is how we deploy this configuration securely using Terraform:

s3_cross_account.tf
# S3 Bucket in Account B
resource "aws_s3_bucket" "archive" {
  bucket = "company-data-archive-222222222222"
}

# Enforce Bucket Owner Ownership (Disables ACLs)
resource "aws_s3_bucket_ownership_controls" "archive" {
  bucket = aws_s3_bucket.archive.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

# Apply the Bucket Policy
resource "aws_s3_bucket_policy" "archive" {
  bucket     = aws_s3_bucket.archive.id
  depends_on = [aws_s3_bucket_ownership_controls.archive]

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "AllowCrossAccountUpload"
        Effect    = "Allow"
        Principal = {
          AWS = "arn:aws:iam::111111111111:role/data-uploader-role"
        }
        Action    = [
          "s3:PutObject",
          "s3:PutObjectAcl"
        ]
        Resource  = "${aws_s3_bucket.archive.arn}/*"
      }
    ]
  })
}

Testing the Upload via AWS CLI

To verify the setup, run this upload test from Account A's terminal using the data-uploader-role:

terminal
dinesh@acct-a ~ ❯ aws s3 cp backup.tar.gz s3://company-data-archive-222222222222/ --acl bucket-owner-full-control
upload: ./backup.tar.gz to s3://company-data-archive-222222222222/backup.tar.gz

Conclusion

Configuring S3 cross-account access requires coordinating three key configurations: the destination bucket policy, the source IAM policy, and S3 Object Ownership settings. By disabling ACLs and enforcing BucketOwnerEnforced, you prevent object ownership issues and ensure secure, seamless data transfers across your cloud accounts.

Set up these policies inside your IaC repositories to scale your multi-account architectures safely!