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 AssumeRole calls (query costs apply)
  • Enable bucket versioning for audit trails
  • Set role session duration in IAM role Trust relationshipsEditMaximum session duration (default: 1 hour, max: 12 hours)
  • Require MFA for role assumption (add Condition with aws:MultiFactorAuthPresent in trust policy)

Create IAM Policy

IAM ConsolePoliciesCreate policyJSON

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 ConsoleRolesCreate roleAnother 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 ConsoleCreate bucket

  1. Bucket name: client-uploads-production
  2. Default encryption: Enable SSE-S3 (AES-256)
  3. Block Public Access: Enable all four settings
  4. 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 tabBucket 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:

  1. Check CloudTrail for AssumeRole events from external account ID
  2. Check S3 access logs (if enabled) for successful PUT operations
  3. 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:AssumeRole policy 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-S3Access policy 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/ not s3://BUCKET_NAME/