Cross-Account S3 Access Without Sharing Credentials
Need to let an external AWS account upload to your S3 bucket? Skip the credential sharing—use cross-account role assumption with External IDs. This gives you auditable, revocable access without long-term credentials.
Why this approach? Creating IAM users in your account for external parties means managing credentials, rotation, and revocation. Role assumption uses temporary credentials that expire automatically (default 1 hour).
You'll need: External party's 12-digit AWS Account ID
Before you start: If you need customer-managed encryption keys (KMS), decide now. KMS requires different IAM permissions (kms:Decrypt, kms:GenerateDataKey) and adds setup complexity. For most use cases, SSE-S3 (AWS-managed keys) is sufficient.
This guide covers: New bucket creation with a specific client folder. For existing buckets, skip to the IAM Policy section and modify the bucket policy carefully to avoid disrupting current access.
Optional hardening:
- Add IP restrictions if client has static IPs
- Enable CloudTrail to monitor
AssumeRolecalls (query costs apply) - Enable bucket versioning for audit trails
- Set role session duration in IAM role Trust relationships → Edit → Maximum session duration (default: 1 hour, max: 12 hours)
- Require MFA for role assumption (add
Conditionwithaws:MultiFactorAuthPresentin trust policy)
Create IAM Policy
IAM Console → Policies → Create policy → JSON
Name: CLIENT_NAME-S3Access
Option A: Restrict to folder (recommended)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowListBucket",
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::BUCKET_NAME"],
"Condition": {
"StringLike": {
"s3:prefix": ["CLIENT_NAME/*"]
}
}
},
{
"Sid": "AllowObjectOperations",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": ["arn:aws:s3:::BUCKET_NAME/CLIENT_NAME/*"]
}
]
}
Option B: Full bucket access
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowListBucket",
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": ["arn:aws:s3:::BUCKET_NAME"]
},
{
"Sid": "AllowObjectOperations",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": ["arn:aws:s3:::BUCKET_NAME/*"]
}
]
}
Add s3:DeleteObject to the Action array if the client needs delete permissions.
Create IAM Role
IAM Console → Roles → Create role → Another AWS account
- Account ID: External party's 12-digit ID
- Require external ID: Use a unique string (e.g.,
CLIENT_NAME-20190607) — Acts as a password; even if someone discovers your role ARN, they can't assume it without the External ID - Attach policy:
CLIENT_NAME-S3Access - Role name:
CLIENT_NAME-CrossAccountRole
Create S3 Bucket
S3 Console → Create bucket
- Bucket name:
client-uploads-production - Default encryption: Enable SSE-S3 (AES-256)
- Block Public Access: Enable all four settings
- Server access logging (optional): Enable if you have a logging bucket (e.g.,
aws-logs-YOUR_ACCOUNT_ID-us-east-1), prefix:s3/CLIENT_NAME/. Skip if you don't have one set up—you can add this later.
After bucket creation, add this policy to enforce HTTPS:
Bucket Permissions tab → Bucket Policy → Paste:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::BUCKET_NAME",
"arn:aws:s3:::BUCKET_NAME/*"
],
"Condition": {
"Bool": {"aws:SecureTransport": "false"}
}
}]
}
Handoff
Send to external party:
Bucket: BUCKET_NAME
Folder: CLIENT_NAME/
Role ARN: arn:aws:iam::YOUR_ACCOUNT_ID:role/CLIENT_NAME-CrossAccountRole
External ID: CLIENT_NAME-20190607
They must attach this policy to the IAM user or role that will be making S3 API calls (e.g., the user running AWS CLI or the EC2 instance role):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::YOUR_ACCOUNT_ID:role/CLIENT_NAME-CrossAccountRole"
}]
}
Verification
External party adds to ~/.aws/config:
[profile client-role]
role_arn = arn:aws:iam::YOUR_ACCOUNT_ID:role/CLIENT_NAME-CrossAccountRole
external_id = CLIENT_NAME-20190607
source_profile = default
Then tests:
# Upload a test file
echo "test" > test.txt
aws s3 cp test.txt s3://BUCKET_NAME/CLIENT_NAME/ --profile client-role
# List to verify
aws s3 ls s3://BUCKET_NAME/CLIENT_NAME/ --profile client-role
You (bucket owner) verify by:
- Check CloudTrail for
AssumeRoleevents from external account ID - Check S3 access logs (if enabled) for successful PUT operations
- Verify test file appears in S3 Console under
CLIENT_NAME/folder
Troubleshooting
Access Denied when assuming role:
- Verify External ID matches exactly (case-sensitive)
- Check external party attached the
sts:AssumeRolepolicy to their IAM user/role that's making the API calls - Confirm trust relationship on your role (
CLIENT_NAME-CrossAccountRole) has correct external account ID - Verify role ARN is correct in their AWS config
Access Denied to S3 bucket:
- Confirm role has
CLIENT_NAME-S3Accesspolicy attached - Check bucket policy isn't blocking cross-account access
- Verify bucket name and folder prefix match exactly in policy ARNs
- Ensure external party is using HTTPS (HTTP is blocked by bucket policy)
- If using folder restriction, confirm they're uploading to
s3://BUCKET_NAME/CLIENT_NAME/nots3://BUCKET_NAME/