Field Notes Blog cover
Data Engineering

AWS DataSync for S3: Same-Account and Cross-Account Transfer Without the Surprises

The console can't see the bucket. The role needs to be in the destination account. KMS is almost always the first thing that breaks. Here's the complete playbook.

A
Ashutosh Upadhyay
Platform Engineering · August 2026

AWS DataSync handles large-scale S3 data movement with scheduling, incremental sync, and data verification built in. For a one-off copy, aws s3 sync is fine. DataSync earns its place when you need reliable, repeatable transfers — especially across account boundaries — without homegrown retry logic and CloudWatch dashboards you had to build yourself.

The same-account setup is simple. The cross-account setup has three non-obvious requirements that nobody mentions together: the role must live in the destination account, the source bucket needs its own bucket policy, and the console can't be used to create cross-account locations. This post walks both setups end-to-end.

Why DataSync Instead of aws s3 sync

For ongoing transfers between two S3 locations, DataSync provides capabilities that a script wrapping the CLI cannot easily replicate:

If you're running a scheduled nightly copy from one bucket to another, DataSync is significantly less code to maintain than a custom solution. The cross-account capability is what makes it particularly useful in multi-account AWS organizations.

Same-Account Setup: The Simple Case

For same-account transfers, the setup is three objects: an IAM role, a source location, and a destination location — linked together in a task.

Step 1: Create the IAM Role

DataSync needs a role it can assume. The trust policy should always include aws:SourceAccount and aws:SourceArn conditions, even for same-account setups. These conditions guard against the confused-deputy problem — without them, any DataSync task in any account could theoretically assume the role if they obtained the ARN.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "datasync.amazonaws.com"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "aws:SourceAccount": "ACCOUNT_ID"
        },
        "ArnLike": {
          "aws:SourceArn": "arn:aws:datasync:REGION:ACCOUNT_ID:*"
        }
      }
    }
  ]
}
aws iam create-role \
  --role-name DataSyncS3Role \
  --assume-role-policy-document file://trust-policy.json

Step 2: Attach the Permissions Policy

The permissions policy needs read access to the source bucket and read-write access to the destination bucket. For same-account, both references use the same account ID:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SourceBucketRead",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:GetObjectTagging",
        "s3:ListMultipartUploadParts"
      ],
      "Resource": "arn:aws:s3:::SOURCE-BUCKET/*"
    },
    {
      "Sid": "SourceBucketList",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:GetBucketLocation",
        "s3:ListBucketMultipartUploads"
      ],
      "Resource": "arn:aws:s3:::SOURCE-BUCKET"
    },
    {
      "Sid": "DestBucketWrite",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:GetObjectTagging",
        "s3:PutObject",
        "s3:PutObjectTagging",
        "s3:DeleteObject",
        "s3:ListMultipartUploadParts",
        "s3:AbortMultipartUpload"
      ],
      "Resource": "arn:aws:s3:::DEST-BUCKET/*"
    },
    {
      "Sid": "DestBucketList",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:GetBucketLocation",
        "s3:ListBucketMultipartUploads"
      ],
      "Resource": "arn:aws:s3:::DEST-BUCKET"
    }
  ]
}
aws iam put-role-policy \
  --role-name DataSyncS3Role \
  --policy-name DataSyncS3Access \
  --policy-document file://permissions.json

For same-account, you can now create the source and destination locations through the DataSync console — it can enumerate your S3 buckets in the same account without any additional configuration.

Cross-Account Setup: Why It's Different

AWS documents two topologies for cross-account DataSync. We use and document the destination-account-driven approach here — task and IAM role in Account A (the receiving account) — because it avoids a mandatory extra step that the source-account-driven approach requires (disabling ACLs on the destination bucket). Both topologies work; the tradeoff is covered in the gotcha table below.

In the destination-account topology: DataSync uses the IAM role in the account where the task runs. The task runs in Account A, the role is in Account A, and that single role handles both the source location (Account B's bucket) and the destination location (Account A's bucket).

Account A (DataSync + Destination)        Account B (Source)
┌──────────────────────────────────┐      ┌────────────────────────────┐
│  DataSync Task                    │      │  S3 Bucket (source)        │
│    ├─ Source Location  ───────────┼─────▶│    Bucket Policy           │
│    │   (points to B's bucket)     │      │    grants Account A        │
│    │                              │      │    DataSync role access    │
│    └─ Destination Location        │      └────────────────────────────┘
│        (A's bucket)               │
│                                   │
│  IAM Role: DataSyncCrossAcctRole  │
│  (assumed by datasync.amazonaws.com)
└──────────────────────────────────┘

Topology shown: task and role run in Account A (destination). AWS also documents a source-account-driven topology (task in Account B) — that approach requires disabling ACLs on Account A's destination bucket to ensure Account A owns the transferred objects.

Step 1: Create the IAM Role in Account A

Same trust policy as same-account, but with Account A's ID:

aws iam create-role \
  --role-name DataSyncCrossAcctRole \
  --assume-role-policy-document file://trust-policy.json

Step 2: Attach Permissions for Both Buckets

The permissions policy now references Account B's bucket for the source actions and Account A's bucket for the destination actions. The key difference: the source bucket ARN does not include an account ID — S3 bucket ARNs are globally unique by name, not by account.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SourceBucketAccountB",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:GetObjectTagging",
        "s3:ListMultipartUploadParts"
      ],
      "Resource": "arn:aws:s3:::ACCOUNT-B-SOURCE-BUCKET/*"
    },
    {
      "Sid": "SourceBucketListAccountB",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:GetBucketLocation",
        "s3:ListBucketMultipartUploads"
      ],
      "Resource": "arn:aws:s3:::ACCOUNT-B-SOURCE-BUCKET"
    },
    {
      "Sid": "DestBucketAccountA",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:GetObjectTagging",
        "s3:PutObject",
        "s3:PutObjectTagging",
        "s3:DeleteObject",
        "s3:ListMultipartUploadParts",
        "s3:AbortMultipartUpload"
      ],
      "Resource": "arn:aws:s3:::ACCOUNT-A-DEST-BUCKET/*"
    },
    {
      "Sid": "DestBucketListAccountA",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:GetBucketLocation",
        "s3:ListBucketMultipartUploads"
      ],
      "Resource": "arn:aws:s3:::ACCOUNT-A-DEST-BUCKET"
    }
  ]
}

Step 3: Add a Bucket Policy in Account B (the Source)

This step is the one most commonly skipped — and without it, you get AccessDenied regardless of what your IAM policy in Account A says. S3 cross-account access requires both the identity policy (in Account A) and the resource policy (the bucket policy in Account B) to allow the access. Neither alone is sufficient.

Run this with Account B credentials:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowDataSyncFromAccountA",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::ACCOUNT-A-ID:role/DataSyncCrossAcctRole"
      },
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:GetObjectTagging",
        "s3:ListMultipartUploadParts",
        "s3:ListBucket",
        "s3:GetBucketLocation",
        "s3:ListBucketMultipartUploads"
      ],
      "Resource": [
        "arn:aws:s3:::ACCOUNT-B-SOURCE-BUCKET",
        "arn:aws:s3:::ACCOUNT-B-SOURCE-BUCKET/*"
      ]
    }
  ]
}
# Run with Account B credentials
aws s3api put-bucket-policy \
  --bucket ACCOUNT-B-SOURCE-BUCKET \
  --policy file://source-bucket-policy.json

Step 4: Create the Source Location via CLI Only

The DataSync console cannot enumerate buckets in Account B — it only lists buckets visible to the current account's credentials. For cross-account source locations, the CLI is the only reliable path:

aws datasync create-location-s3 \
  --s3-bucket-arn "arn:aws:s3:::ACCOUNT-B-SOURCE-BUCKET" \
  --s3-config '{"BucketAccessRoleArn":"arn:aws:iam::ACCOUNT-A-ID:role/DataSyncCrossAcctRole"}' \
  --subdirectory "/optional/prefix" \
  --region REGION

Save the returned LocationArn as SOURCE_LOCATION_ARN.

Step 5: Create the Destination Location

The destination is in Account A — you can use the console or CLI:

aws datasync create-location-s3 \
  --s3-bucket-arn "arn:aws:s3:::ACCOUNT-A-DEST-BUCKET" \
  --s3-config '{"BucketAccessRoleArn":"arn:aws:iam::ACCOUNT-A-ID:role/DataSyncCrossAcctRole"}' \
  --region REGION

Step 6: Create and Run the Task

aws datasync create-task \
  --source-location-arn "SOURCE_LOCATION_ARN" \
  --destination-location-arn "DEST_LOCATION_ARN" \
  --name "CrossAccountCopy" \
  --options '{
    "VerifyMode":"ONLY_FILES_TRANSFERRED",
    "OverwriteMode":"ALWAYS",
    "PreserveDeletedFiles":"PRESERVE"
  }' \
  --region REGION

# Start it
aws datasync start-task-execution \
  --task-arn "TASK_ARN" \
  --region REGION

# Monitor
aws datasync describe-task-execution \
  --task-execution-arn "TASK_EXECUTION_ARN" \
  --region REGION

The KMS Gotcha

If either bucket uses SSE-KMS encryption, the IAM policy alone is not sufficient — you must also update the KMS key policies in the relevant accounts. This is the most common failure point in cross-account DataSync setups, and the error message is a generic AccessDenied that doesn't mention KMS.

Source Bucket in Account B Uses KMS

The DataSync role in Account A needs kms:Decrypt and kms:DescribeKey added to Account B's KMS key policy:

{
  "Sid": "AllowDataSyncRoleDecrypt",
  "Effect": "Allow",
  "Principal": {
    "AWS": "arn:aws:iam::ACCOUNT-A-ID:role/DataSyncCrossAcctRole"
  },
  "Action": [
    "kms:Decrypt",
    "kms:DescribeKey"
  ],
  "Resource": "*"
}

Destination Bucket in Account A Uses KMS

The DataSync role needs kms:GenerateDataKey, kms:Encrypt, and kms:Decrypt on Account A's KMS key. This is an identity-based permission (IAM policy) or a key policy addition — either works for same-account keys:

{
  "Effect": "Allow",
  "Action": [
    "kms:GenerateDataKey",
    "kms:Encrypt",
    "kms:Decrypt",
    "kms:DescribeKey"
  ],
  "Resource": "arn:aws:kms:REGION:ACCOUNT-A-ID:key/KEY-ID"
}

KMS is a two-layer permission system. For cross-account KMS access, both the IAM policy on the role AND the KMS key policy in the target account must allow the operation. IAM alone is insufficient for cross-account key usage. Expect AccessDenied on your first run if either bucket is KMS-encrypted and you haven't updated the key policy.

Task Options Worth Knowing

The --options parameter on create-task controls transfer behavior. The defaults are not always what you want:

Option Recommended Value When to Change
VerifyMode ONLY_FILES_TRANSFERRED Use POINT_IN_TIME_CONSISTENT for full integrity checks; NONE to skip verification entirely and maximize throughput
OverwriteMode ALWAYS Use NEVER if you don't want to overwrite objects that have changed at the destination
PreserveDeletedFiles PRESERVE Change to REMOVE only if you want the destination to mirror the source including deletions
TransferMode CHANGED Use ALL to force retransfer of all files regardless of whether they changed

Five Gotchas in One Place

# Gotcha Consequence Fix
1 Console can't see cross-account buckets You can't create the source location through the UI Use aws datasync create-location-s3 CLI command
2 Bucket policy on source is mandatory AccessDenied even with correct IAM policy Add bucket policy in Account B granting the Account A role access
3 KMS requires key policy updates Generic AccessDenied with no KMS mention Update key policies in both accounts for encrypted buckets
4 Missing confused-deputy conditions Any DataSync task could assume the role Always include aws:SourceAccount + aws:SourceArn in trust policy
5 Cross-region transfers add data transfer cost Unexpected AWS bill line items Use same region where possible; account for transfer charges if not

Object ownership — destination-account topology: When DataSync in Account A writes to Account A's bucket using Account A's role, Account A automatically owns the objects. Ownership follows from which account's role performed the write — no ACL configuration needed.

Source-account topology note: In AWS's alternative documented pattern (task and role in Account B, the source), the role writes into Account A's bucket from Account B's credentials. In that case, Account B would own the transferred objects by default — to make Account A own them, you must disable ACLs on Account A's destination bucket first (S3 Object Ownership → Bucket owner enforced). AWS lists this as a required step in their cross-account DataSync tutorial.

Key Takeaways

AWS DataSync S3 Cross-Account IAM Data Engineering KMS
Share

Comments & Discussion