Find S3 Buckets That Still Use ACLs and Disable Them

Rows of server racks in a data center

Photo by imgix on Unsplash

To disable S3 ACLs, set Object Ownership to Bucket owner enforced with PutBucketOwnershipControls. First find the buckets that need it: GetBucketOwnershipControls returns ObjectWriter or BucketOwnerPreferred when ACLs are on, or fails with OwnershipControlsNotFoundError on older buckets with no setting. Then check GetBucketAcl, because S3 refuses the change while the bucket ACL grants anyone but the owner.

Access control lists are S3’s original permission system, older than IAM. Since April 2023, new buckets are created with ACLs disabled, but buckets created before that keep whatever they had, and many still honor ACL grants nobody remembers adding. This example is for engineers who want to find the buckets that still use ACLs, see what depends on them, and disable S3 ACLs with the Bucket owner enforced setting where it’s safe.

The TypeScript script uses the AWS SDK for JavaScript v3. It reports by default and changes a bucket only with --apply, like the example to find public and private S3 buckets with the AWS SDK, which reads ACLs to decide whether a bucket is public.

What do the Object Ownership settings mean?

Setting ACLs Who owns new objects
BucketOwnerEnforced Disabled: they no longer affect access, and requests that set ACLs fail The bucket owner, always
BucketOwnerPreferred Enabled The bucket owner if the upload uses the bucket-owner-full-control canned ACL, else the writer
ObjectWriter Enabled The account that uploaded the object
No setting Enabled The uploading account, as with ObjectWriter

A bucket has no setting when it was created before BucketOwnerEnforced existed and nobody applied one, or when someone deleted it with DeleteBucketOwnershipControls. AWS’s announcement of the April 2023 S3 default changes confirms the new defaults applied only to newly created buckets. Security Hub tracks the same issue as control S3.12, “ACLs should not be used to manage user access to S3 general purpose buckets.”

What does the script check?

  1. Lists bucketsListBuckets with the paginator, using each bucket’s BucketRegion for the next calls.
  2. Reads Object OwnershipGetBucketOwnershipControls; OwnershipControlsNotFoundError is reported as none (ACLs on).
  3. Reads the bucket ACLGetBucketAcl, keeping every grant that isn’t to the bucket owner and naming the well-known grantees: AllUsers, AuthenticatedUsers, the S3 LogDelivery group and CloudFront’s log delivery account.
  4. Assigns a verdictok when ACLs are already disabled, READY when ACLs are on but the bucket ACL grants only the owner, MIGRATE when other grants exist.
  5. Disables ACLs on requestWith --apply, PutBucketOwnershipControls sets BucketOwnerEnforced on READY buckets only.

Prerequisites

Which IAM permissions does it need?

The last statement is only for --apply.

s3-acl-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBuckets",
      "Effect": "Allow",
      "Action": "s3:ListAllMyBuckets",
      "Resource": "*"
    },
    {
      "Sid": "ReadOwnershipAndAcl",
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketOwnershipControls",
        "s3:GetBucketAcl"
      ],
      "Resource": "arn:aws:s3:::*"
    },
    {
      "Sid": "DisableAclsWithApply",
      "Effect": "Allow",
      "Action": "s3:PutBucketOwnershipControls",
      "Resource": "arn:aws:s3:::*"
    }
  ]
}

The IAM policy generator for TypeScript AWS SDK code produces a draft if you add calls; review it before attaching.

The script

find-s3-buckets-with-acls-enabled.ts

// find-s3-buckets-with-acls-enabled.ts
// Reports each S3 general purpose bucket's Object Ownership setting (BucketOwnerEnforced, BucketOwnerPreferred,
// ObjectWriter, or none set) and the bucket ACL grants that go to anyone other than the bucket owner, including
// the S3 log delivery group and CloudFront's legacy log delivery account. Report-only by default.
// --apply sets BucketOwnerEnforced (ACLs disabled) on buckets whose bucket ACL grants only the owner.
// Usage: npx tsx find-s3-buckets-with-acls-enabled.ts [--buckets=a,b] [--apply]
import {
  S3Client,
  GetBucketAclCommand,
  GetBucketOwnershipControlsCommand,
  PutBucketOwnershipControlsCommand,
  paginateListBuckets,
  type Grant,
} from "@aws-sdk/client-s3";

const args = process.argv.slice(2);
const apply = args.includes("--apply");
const onlyBuckets = args.find((a) => a.startsWith("--buckets="))?.split("=")[1]?.split(",").map((s) => s.trim()).filter(Boolean);

// Canonical user ID of the awslogsdelivery account that writes CloudFront standard logs (legacy).
const CLOUDFRONT_LOGS = "c4c1ede66af53448b93c283ce9448c4ba468c9432aa01d700d3878632f77d2d0";
const GROUPS: Record<string, string> = {
  "http://acs.amazonaws.com/groups/global/AllUsers": "AllUsers (public)",
  "http://acs.amazonaws.com/groups/global/AuthenticatedUsers": "AuthenticatedUsers (any AWS account)",
  "http://acs.amazonaws.com/groups/s3/LogDelivery": "S3 LogDelivery group",
};

interface Row { Bucket: string; Region: string; Ownership: string; ExtraGrants: string; Verdict: string }

const errName = (err: unknown): string => (err instanceof Error ? err.name : String(err));

function describeGrant(g: Grant): string {
  const who = g.Grantee;
  let name = "unknown grantee";
  if (who?.Type === "Group") name = GROUPS[who.URI ?? ""] ?? who.URI ?? "group";
  else if (who?.ID === CLOUDFRONT_LOGS) name = "CloudFront log delivery";
  else if (who?.Type === "CanonicalUser") name = `account ${(who.ID ?? "").slice(0, 8)}…`;
  else if (who?.Type === "AmazonCustomerByEmail") name = "account by email";
  return `${name}:${g.Permission ?? "?"}`;
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  const home = new S3Client({ region: "us-east-1" });

  for await (const page of paginateListBuckets({ client: home }, {})) {
    for (const b of page.Buckets ?? []) {
      const bucket = b.Name ?? "";
      if (!bucket || (onlyBuckets && !onlyBuckets.includes(bucket))) continue;
      const region = b.BucketRegion ?? "us-east-1";
      const s3 = new S3Client({ region });

      let ownership: string;
      try {
        const out = await s3.send(new GetBucketOwnershipControlsCommand({ Bucket: bucket }));
        ownership = out.OwnershipControls?.Rules?.[0]?.ObjectOwnership ?? "?";
      } catch (err) {
        // No setting at all: an older bucket (or one whose setting was deleted). ACLs are enabled.
        ownership = errName(err) === "OwnershipControlsNotFoundError" ? "none (ACLs on)" : `error: ${errName(err)}`;
      }

      let extra: string[] = [];
      try {
        const acl = await s3.send(new GetBucketAclCommand({ Bucket: bucket }));
        const ownerId = acl.Owner?.ID;
        extra = (acl.Grants ?? []).filter((g) => g.Grantee?.ID !== ownerId).map(describeGrant);
      } catch (err) {
        extra = [`error: ${errName(err)}`];
      }

      let verdict: string;
      if (ownership === "BucketOwnerEnforced") verdict = "ok: ACLs disabled";
      else if (ownership.startsWith("error")) verdict = "check permissions";
      else if (extra.length) verdict = "MIGRATE: move ACL grants to a bucket policy first";
      else verdict = "READY: ACLs on, bucket ACL grants only the owner";
      rows.push({ Bucket: bucket, Region: region, Ownership: ownership, ExtraGrants: extra.join("; ") || "-", Verdict: verdict });
    }
  }

  console.table(rows);
  const ready = rows.filter((r) => r.Verdict.startsWith("READY"));
  const migrate = rows.filter((r) => r.Verdict.startsWith("MIGRATE"));
  console.log(`${rows.length} bucket(s) checked; ${ready.length} ready to disable ACLs, ${migrate.length} need their ACL grants migrated first.`);
  if (!apply) {
    console.log("Report only: nothing changed. --apply would set BucketOwnerEnforced on the READY buckets.");
    if (ready.length || migrate.length) process.exitCode = 2;
    return;
  }

  // Only READY buckets: S3 rejects BucketOwnerEnforced while the bucket ACL grants anyone but the owner
  // (InvalidBucketAclWithObjectOwnership), and those grants usually mean something depends on them.
  for (const r of ready) {
    try {
      await new S3Client({ region: r.Region }).send(
        new PutBucketOwnershipControlsCommand({
          Bucket: r.Bucket,
          OwnershipControls: { Rules: [{ ObjectOwnership: "BucketOwnerEnforced" }] },
        }),
      );
      console.log(`${r.Bucket}: BucketOwnerEnforced set (was ${r.Ownership})`);
    } catch (err) {
      console.log(`${r.Bucket}: failed (${err instanceof Error ? err.message : String(err)})`);
    }
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-s3
npm install --save-dev tsx typescript

# Report every bucket
AWS_PROFILE=security-audit npx tsx find-s3-buckets-with-acls-enabled.ts

# Disable ACLs on two READY buckets
AWS_PROFILE=storage-admin npx tsx find-s3-buckets-with-acls-enabled.ts --buckets=acme-terraform-state,acme-partner-drop --apply

The report exits with code 2 when any bucket is READY or MIGRATE, so you can track progress in CI.

Sample output

Output

┌─────────┬────────────────────────┬─────────────┬────────────────────────┬─────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────┐
│ (index) │ Bucket                 │ Region      │ Ownership              │ ExtraGrants                                                 │ Verdict                                             │
├─────────┼────────────────────────┼─────────────┼────────────────────────┼─────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────┤
│ 0       │ 'acme-app-uploads'     │ 'us-east-1' │ 'BucketOwnerEnforced'  │ '-'                                                         │ 'ok: ACLs disabled'                                 │
│ 1       │ 'acme-cdn-logs'        │ 'us-east-1' │ 'ObjectWriter'         │ 'CloudFront log delivery:FULL_CONTROL'                      │ 'MIGRATE: move ACL grants to a bucket policy first' │
│ 2       │ 'acme-legacy-assets'   │ 'eu-west-1' │ 'none (ACLs on)'       │ 'AllUsers (public):READ'                                    │ 'MIGRATE: move ACL grants to a bucket policy first' │
│ 3       │ 'acme-partner-drop'    │ 'eu-west-1' │ 'BucketOwnerPreferred' │ '-'                                                         │ 'READY: ACLs on, bucket ACL grants only the owner'  │
│ 4       │ 'acme-s3-access-logs'  │ 'us-east-1' │ 'none (ACLs on)'       │ 'S3 LogDelivery group:WRITE; S3 LogDelivery group:READ_ACP' │ 'MIGRATE: move ACL grants to a bucket policy first' │
│ 5       │ 'acme-terraform-state' │ 'us-east-1' │ 'none (ACLs on)'       │ '-'                                                         │ 'READY: ACLs on, bucket ACL grants only the owner'  │
└─────────┴────────────────────────┴─────────────┴────────────────────────┴─────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────┘
6 bucket(s) checked; 2 ready to disable ACLs, 3 need their ACL grants migrated first.
Report only: nothing changed. --apply would set BucketOwnerEnforced on the READY buckets.

The names are illustrative. acme-terraform-state is a typical older bucket: no setting, nothing granted, safe to switch. acme-legacy-assets is public through an ACL, so it needs a decision before anything else. The two logging buckets show the most common dependency: log delivery that still works through ACL grants.

What should you check before you disable S3 ACLs on a bucket?

  • Bucket ACL grants. Migrate each grant to a bucket policy, then reset the ACL with aws s3api put-bucket-acl --acl private. Until then, S3 rejects BucketOwnerEnforced with InvalidBucketAclWithObjectOwnership.
  • S3 server access logging targets. Replace the LogDelivery group grant with a bucket policy that allows the logging.s3.amazonaws.com service principal to s3:PutObject, conditioned on the source bucket and account.
  • CloudFront standard logging (legacy). It writes through an ACL grant to the awslogsdelivery account and needs ACLs enabled on the target bucket. Disabling them stops those logs, so leave that bucket alone or change the distribution’s logging first. The script to check the CloudFront minimum TLS version on every distribution is a quick way to list your distributions.
  • Object ACLs. The script doesn’t read them; that takes one call per object. Instead, look for aclRequired in S3 server access logs or CloudTrail: it’s Yes on requests that needed an ACL. Object-level requests reach CloudTrail only as S3 data events, and only if a trail is running, so first check that CloudTrail is logging in every AWS Region.
  • Uploaders that set ACLs. After the switch, PUT requests that specify any ACL other than bucket-owner-full-control fail with AccessControlListNotSupported. Search your code for ACL: parameters, for example in code that uploads a file to S3 with S3Client in TypeScript or creates presigned S3 upload URLs with AWS SDK v3.
  • Bucket policy conditions on ACL headers. A policy that requires s3:x-amz-acl to be bucket-owner-full-control keeps working; one that requires another ACL such as public-read must be updated.

The change is reversible: switching back to another setting restores the earlier bucket and object ACLs, although objects written while ACLs were disabled stay owned by the bucket owner.

Troubleshooting

  • InvalidBucketAclWithObjectOwnership on --apply. The bucket ACL changed after the report. Run the report again; the bucket now shows as MIGRATE.
  • AccessDenied on GetBucketAcl. Check the audit role and any SCPs with the guide to troubleshoot AWS IAM access denied errors step by step.
  • An application fails with AccessControlListNotSupported after the switch. It sends an ACL on upload. Remove the ACL parameter, or send bucket-owner-full-control.

Other per-bucket settings worth checking in the same pass: the scripts to find S3 buckets without versioning enabled, find S3 buckets without lifecycle rules and find S3 buckets whose bucket policy doesn’t require HTTPS.

Ask ChatWithCloud instead

To check one bucket quickly, ask ChatWithCloud “What is the Object Ownership setting of my logs bucket, and who does its ACL grant access to?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result. Because generated code runs without a confirmation step, connect ChatWithCloud with a read-only AWS profile and make the change with this script; the ChatWithCloud security model explains what’s sent for processing.

Frequently asked questions

How do I disable ACLs on an existing S3 bucket?

Migrate any bucket ACL grants to a bucket policy, reset the bucket ACL to private, then set Object Ownership to BucketOwnerEnforced with PutBucketOwnershipControls or the console’s Permissions tab.

Are ACLs disabled by default on new S3 buckets?

Yes. Since the April 2023 rollout, new buckets use BucketOwnerEnforced. Buckets created earlier keep their previous setting.

What happens to existing object ACLs when I disable ACLs?

They stay stored but no longer affect access. If you re-enable ACLs later, they take effect again.

Can I still upload with the bucket-owner-full-control ACL?

Yes. Uploads with no ACL or with bucket-owner-full-control succeed; any other ACL fails with AccessControlListNotSupported.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud