S3 cross-account access means you have data in an S3 bucket in AccountA and need to access it from AccountB. This usually involves two main components: a bucket policy on the resource bucket (AccountA) and an IAM role in the accessing account (AccountB).

Let’s see this in action. Imagine AccountA has a bucket named account-a-data-bucket and AccountB needs to read objects from it.

First, in AccountA, we’ll attach a bucket policy to account-a-data-bucket. This policy grants specific permissions to an IAM role in AccountB.

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::ACCOUNTB_ID:role/AccountBDataAccessRole"
            },
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::account-a-data-bucket",
                "arn:aws:s3:::account-a-data-bucket/*"
            ]
        }
    ]
}

Here, ACCOUNTB_ID is the 12-digit AWS account ID for AccountB. The Principal specifies who is allowed to perform actions. In this case, it’s the IAM role AccountBDataAccessRole from AccountB. The Action lists the S3 operations allowed, and Resource defines which S3 resources these actions apply to: the bucket itself (for ListBucket) and all objects within it (for GetObject).

Next, in AccountB, we create an IAM role, AccountBDataAccessRole. This role needs two things: a trust relationship that allows AccountA to assume it, and an inline or attached policy that grants permissions to access AccountA’s bucket.

The trust relationship for AccountBDataAccessRole in AccountB looks like this:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::ACCOUNT_A_ID:root"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

This AssumeRole policy allows the AWS root account of AccountA to initiate the sts:AssumeRole API call. When a user or service in AccountB assumes this role, they gain temporary credentials with the permissions defined in the role’s attached policies.

The permissions policy attached to AccountBDataAccessRole in AccountB would look like this:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::account-a-data-bucket",
                "arn:aws:s3:::account-a-data-bucket/*"
            ]
        }
    ]
}

This policy mirrors the permissions granted in the bucket policy, ensuring that the role itself has the necessary permissions to perform the desired actions.

Now, a user or application in AccountB can assume AccountBDataAccessRole. Once assumed, they can use the AWS CLI or SDKs to interact with account-a-data-bucket in AccountA. For example, using the AWS CLI:

# First, assume the role and get temporary credentials
aws sts assume-role --role-arn arn:aws:iam::ACCOUNTB_ID:role/AccountBDataAccessRole --role-session-name "MySession"

# This will output JSON with AccessKeyId, SecretAccessKey, and SessionToken.
# Configure your environment with these temporary credentials (e.g., via environment variables or ~/.aws/credentials)

# Then, list objects in the cross-account bucket
aws s3 ls s3://account-a-data-bucket --profile <your-profile-with-temp-creds>

# Download an object
aws s3 cp s3://account-a-data-bucket/my-data.txt . --profile <your-profile-with-temp-creds>

The mental model here is that AccountA’s bucket policy acts as a gatekeeper, specifying who (which IAM principal, in this case a role in AccountB) can access its resources and what they can do. AccountB’s IAM role acts as the identity that users or services in AccountB can borrow to gain those permissions. The sts:AssumeRole action is the handshake that allows AccountB to temporarily adopt the identity defined by the role.

The most surprising part for many is that the bucket policy in AccountA doesn’t need to know anything about the specific users or groups in AccountB who will eventually assume the role. It only needs to trust the role ARN from AccountB. The trust is established at the role level in AccountB allowing AccountA to assume it, and then the permissions are granted by the bucket policy in AccountA to that specific role. It’s a decoupled, but linked, system of trust and permission.

A common point of confusion arises when trying to grant permissions to an entire account using a bucket policy. Instead of specifying a role, you might try to put the AWS: ACCOUNTB_ID directly in the Principal. This will not work as intended for S3 cross-account access. The Principal in an S3 bucket policy must be an IAM principal (user, role, or service principal) or a specific AWS service. To grant access to an account’s resources, you must grant it to an IAM role within that account, which then acts as the gateway for principals in that account.

The next hurdle is often understanding how to manage object ownership when data is uploaded by different accounts, particularly with the default bucket-owner-full-control ACL.

Want structured learning?

Take the full S3 course →