Can you grant S3 access across AWS accounts without sharing keys? Yes — and it’s the right way to do it.
Cross-account S3 bucket access is a foundational pattern for secure cloud architectures. It removes long-lived credentials from shared code, config, and environments by relying instead on IAM roles and bucket policies to delegate temporary, auditable access. This post covers the mechanics, configuration, and one real-world setup you can apply immediately.
🔐 IAM Roles — How Trust Works
An IAM role defines a set of permissions that can be assumed by an AWS entity — an EC2 instance, Lambda function, or another AWS account — but only if trust is explicitly configured.
The mechanism relies on STS (sts:AssumeRole). When a request comes in to assume a role, AWS checks the role’s trust policy (a.k.a. assume role policy) to verify whether the caller is listed in the Principal block. If so, STS issues temporary credentials with a default expiry of 1 hour.
Permissions for what the role can actually do are defined separately by attached IAM policies , such as ones granting s3:GetObject or s3:ListBucket.
For cross-account access, suppose Account A (consumer) needs to read from a bucket in Account B (owner). Account B must create a role with:
1. A trust policy allowing Account A (or a specific role within it) to assume the role
2. An access policy granting S3 read actions on the target bucket
Once assumed, Account A receives temporary credentials via STS — no static keys stored or rotated.
Here’s a trust policy allowing Account A (111122223333) to assume the role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:root"
},
"Action": "sts:AssumeRole",
"Condition": {}
}
]
}
This permits any IAM entity in Account A to attempt assuming the role, provided they have sts:AssumeRole permission. For tighter control, replace :root with a specific role ARN.
📦 S3 Bucket Policies — Controlling Access
A bucket policy is a resource-based policy attached directly to an S3 bucket. It evaluates incoming requests against conditions like principal, action, and IP range.
Because it’s attached to the resource, not the user or role, it’s ideal for defining cross-account permissions. Access requires both the requesting identity’s IAM policies (from the assumed role) and the bucket policy to allow the action. A deny in either results in rejection.
Here’s a bucket policy in Account B allowing a specific role from Account A to list and read objects:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:role/CrossAccountS3Reader"
},
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::shared-data-bucket-example",
"arn:aws:s3:::shared-data-bucket-example/*"
]
}
]
}
Note the two Resource entries:
-
arn:aws:s3:::shared-data-bucket-example→ required forListBucket -
arn:aws:s3:::shared-data-bucket-example/*→ required forGetObject
Apply this policy using the AWS CLI:
$ aws s3api put-bucket-policy \
--bucket shared-data-bucket-example \
--policy file://bucket-policy.json
On success, the command returns nothing — expected behavior for put-bucket-policy. Confirm it applied:
$ aws s3api get-bucket-policy --bucket shared-data-bucket-example
{
"Policy": "{...}"
}
🎯 Principal: Role vs. Account
You can use Principal to specify either an entire account or a specific role. The account-level option:
"json
"Principal": { "AWS": "arn:aws:iam::111122223333:root" }
"
grants any IAM identity in Account A the ability to assume the role, assuming they have sts:AssumeRole. It’s simpler to set up but less secure.
I’d default to specifying the exact role ARN. It reduces blast radius and aligns with least privilege.
🔍 Why Two Policies? (Trust + Access)
The trust policy and bucket policy serve distinct roles:
- Trust policy (in IAM): determines who can assume the role
- Bucket policy (in S3): determines whether the assumed role can access the bucket
Both must permit the action. This separation enables decentralized control — the owning account manages data access, while the consumer account controls identity.
⚡ Testing Access from Account A
With the role created and bucket policy applied, switch to Account A and assume the role:
$ aws sts assume-role \
--role-arn arn:aws:iam::222233334444:role/CrossAccountS3Reader \
--role-session-name TestS3Access
Output includes temporary credentials:
{
"Credentials": {
"AccessKeyId": "ASIA...",
"SecretAccessKey": "...",
"SessionToken": "...",
"Expiration": "2025-04-10T15:30:00Z"
}
}
Export them:
"bash
export AWS_ACCESS_KEY_ID=ASIA…
export AWS_SECRET_ACCESS_KEY=…
export AWS_SESSION_TOKEN=…
"
Now test access:
$ aws s3 ls s3://shared-data-bucket-example/
2024-04-10 14:22:01 1048576 dataset.csv
2024-04-10 14:23:15 204800 config.json
A successful list means the cross-account path is fully operational.
“Cross-account access isn’t about convenience — it’s about removing shared secrets from your architecture.”
⚙️ EC2 Instance Role — Real-World Usage
In practice, you won’t manually assume roles. Instead, you assign an IAM role to an EC2 instance in Account A that has permission to call sts:AssumeRole.
The application running on that instance uses the AWS SDK to programmatically assume the cross-account role and retrieve temporary credentials.
Here’s the flow:
1. EC2 instance starts with IAM role EC2CrossAccountReader
2. That role has sts:AssumeRole permission targeting CrossAccountS3Reader in Account B
3. App calls sts.assume_role() using instance metadata credentials
4. SDK returns temporary credentials valid for up to 1 hour
5. App uses those credentials to call S3
Example using boto3 in Python:
import boto3
# Assume the cross-account role
sts_client = boto3.client('sts')
assumed_role = sts_client.assume_role(
RoleArn='arn:aws:iam::222233334444:role/CrossAccountS3Reader',
RoleSessionName='EC2S3ReaderSession'
)
# Extract temporary credentials
creds = assumed_role['Credentials']
s3_client = boto3.client(
's3',
aws_access_key_id=creds['AccessKeyId'],
aws_secret_access_key=creds['SecretAccessKey'],
aws_session_token=creds['SessionToken']
)
# Now read from the bucket
response = s3_client.list_objects_v2(Bucket='shared-data-bucket-example')
for obj in response.get('Contents', []):
print(obj['Key'])
Behind the scenes, this sends a signed HTTPS request to sts.amazonaws.com. AWS validates the trust policy, confirms the caller has sts:AssumeRole permission, and returns credentials signed with a short TTL.
This keeps secrets out of code, config files, and environment variables — even the assumed credentials are short-lived and automatically rotated.
🚫 Common Pitfalls — What Breaks
Even with correct policies, access often fails due to overlooked constraints.
🔒 Missing s3:ListBucket Permission
Having s3:GetObject doesn't allow aws s3 ls. The ListBucket action must be explicitly allowed in both the role's IAM policy and the bucket policy.
Without it, the CLI returns an empty result or access denied, even if objects exist.
🌐 MFA and Session Duration
Assumed roles expire after 1 hour by default. Long-running processes will lose access unless they re-assume.
You can extend session duration up to 12 hours — but only if the role’s maximum session duration is configured accordingly in the IAM console or via CLI:
$ aws iam update-role \
--role-name CrossAccountS3Reader \
--max-session-duration 43200 # 12 hours in seconds
Also, if the trust policy includes an MFA condition, your assume-role call must include SerialNumber and TokenCode. Otherwise, it fails with InvalidToken.
🛑 Denied by Explicit Deny
Check for explicit Deny statements in:
- The assumed role’s IAM policy
- The bucket policy
- A service control policy (SCP) in an AWS Organization
SCPs are enforced at the organization level and can override all other policies. One time, I spent hours debugging failed S3 access only to find an SCP blocking S3 operations outside the bucket’s region. Always verify SCPs when working in multi-account environments where boundaries are enforced centrally.
🟩 Final Thoughts
Cross-account S3 access shifts how you manage trust — away from shared secrets and toward explicit, time-limited delegation. It's not just about making inter-account data sharing work; it's about making it safe by default.
With IAM roles and bucket policies, you avoid hardcoding credentials, reduce credential sprawl, and get full auditability through CloudTrail. Temporary credentials limit the impact of leaks, and centralized policies make revocation predictable.
Yes, it takes more setup than passing around an access key. But the tradeoff pays off the first time a security review asks, “How do you restrict access across accounts?” You’ll have a real answer — not just a list of who has keys.
This isn’t overhead. It’s architecture.
❓ Frequently Asked Questions
Can I use cross-account S3 access with Lambda?
Yes. Configure your Lambda execution role to allow sts:AssumeRole , then assume the cross-account role in your function using the AWS SDK — same as EC2.
📑 Table of Contents
- 🔐 IAM Roles — How Trust Works
- 📦 S3 Bucket Policies — Controlling Access
- 🎯 Principal: Role vs. Account
- 🔍 Why Two Policies? (Trust + Access)
- ⚡ Testing Access from Account A
- ⚙️ EC2 Instance Role — Real-World Usage
- 🚫 Common Pitfalls — What Breaks
- 🔒 Missing
s3:ListBucketPermission - 🌐 MFA and Session Duration
- 🛑 Denied by Explicit
Deny - 🟩 Final Thoughts
- ❓ Frequently Asked Questions
- Can I use cross-account S3 access with Lambda?
- Do I need to enable anything in both accounts?
- Is there a cost for using STS to assume roles?
- 📚 References & Further Reading
Do I need to enable anything in both accounts?
No special account-level settings are required. As long as IAM and STS are available (they are by default), and the trust and resource policies are correctly configured, it works across regions and accounts.
Is there a cost for using STS to assume roles?
No. STS is free. You only pay for the S3 requests made using the assumed credentials — not for the role assumption itself.
📚 References & Further Reading
- Official AWS guide to cross-account access using roles: docs.aws.amazon.com
- S3 bucket policy examples and structure: docs.aws.amazon.com
- STS assume-role API reference and limits: docs.aws.amazon.com
- IAM roles for EC2 instances and temporary credentials: docs.aws.amazon.com

Top comments (0)