Find Public and Private S3 Buckets With the AWS SDK

An open metal padlock hanging on a chain against a dark background

Photo by Zaqy Al Fattah on Unsplash

To find public S3 buckets, combine four checks: account-level and bucket-level Block Public Access, the bucket policy status from GetBucketPolicyStatus, and ACL grants to the AllUsers or AuthenticatedUsers groups. A bucket is public only if a policy or ACL grants public access and Block Public Access doesn’t cancel it. The script below does all four, read-only.

Many “public bucket” scripts only read ACLs, and they miss the most common way buckets become public today: a bucket policy with "Principal": "*". Others flag a public policy that Block Public Access already neutralizes. This example is for engineers who need to find public S3 buckets accurately, explain why each one is public, and hand a short, defensible list to whoever owns the data.

It’s part of our AWS practical examples for security and cost audits, and pairs with the sibling script to list S3 buckets without versioning enabled. For the same exposure outside S3, the script to find public EBS and RDS snapshots checks disks and databases shared with every AWS account.

What makes an S3 bucket public?

S3 decides public access in layers. The script mirrors them:

  1. Account-level Block Public AccessFour switches set once per account through S3 Control. When a switch is on here, it applies to every bucket, whatever the bucket’s own setting says.
  2. Bucket-level Block Public AccessThe same four switches per bucket. The effective value of each switch is account OR bucket.
  3. Bucket policy statusGetBucketPolicyStatus returns IsPublic: true when S3’s own analysis finds that the policy grants access to anyone, for example "Principal": "*" without a condition that pins it to specific accounts, VPCs or IP ranges. You don’t have to parse the policy yourself.
  4. ACL grantsGetBucketAcl grants to http://acs.amazonaws.com/groups/global/AllUsers (anyone) or .../AuthenticatedUsers (any AWS account holder, which is effectively public too).

How Block Public Access interacts with the grants:

Switch What it does Effect on the verdict
BlockPublicAcls Rejects new public ACLs Doesn’t affect ACLs already in place
IgnorePublicAcls Ignores all public ACLs Public ACL grant becomes BLOCKED
BlockPublicPolicy Rejects new public bucket policies Doesn’t affect a policy already in place
RestrictPublicBuckets Limits a public policy to AWS service principals and the owning account Public policy becomes BLOCKED

So the result is PUBLIC when a public policy isn’t covered by RestrictPublicBuckets or a public ACL isn’t covered by IgnorePublicAcls. It’s BLOCKED when a public grant exists but is neutralized (still worth cleaning up, because turning off one switch would expose it), PRIVATE when no public grant exists, and UNKNOWN when a check couldn’t be read.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • The packages @aws-sdk/client-s3, @aws-sdk/client-s3-control and @aws-sdk/client-sts.
  • A profile in the account that owns the buckets. The account ID comes from STS GetCallerIdentity, which needs no permission.

Which IAM permissions does it need?

All actions are reads. s3:GetAccountPublicAccessBlock and s3:ListAllMyBuckets take "Resource": "*"; the per-bucket reads are scoped to bucket ARNs.

s3-public-buckets-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AccountLevelReads",
      "Effect": "Allow",
      "Action": ["s3:ListAllMyBuckets", "s3:GetAccountPublicAccessBlock"],
      "Resource": "*"
    },
    {
      "Sid": "BucketLevelReads",
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketPublicAccessBlock",
        "s3:GetBucketPolicyStatus",
        "s3:GetBucketAcl"
      ],
      "Resource": "arn:aws:s3:::*"
    }
  ]
}

The checklist to review a generated IAM policy for least privilege covers why the bucket-level actions shouldn’t sit on *. If you adapt the script, run it through the TypeScript IAM policy generator to catch actions you add.

The full script to find public S3 buckets

s3-public-buckets.ts

// s3-public-buckets.ts
// Categorizes every S3 bucket as PUBLIC, BLOCKED or PRIVATE by combining:
//   1. account-level Block Public Access (S3 Control GetPublicAccessBlock)
//   2. bucket-level Block Public Access (S3 GetPublicAccessBlock)
//   3. bucket policy status (GetBucketPolicyStatus -> IsPublic)
//   4. ACL grants to AllUsers / AuthenticatedUsers (GetBucketAcl)
// Read-only. Usage: npx tsx s3-public-buckets.ts [--json]
import {
  S3Client,
  paginateListBuckets,
  GetPublicAccessBlockCommand,
  GetBucketPolicyStatusCommand,
  GetBucketAclCommand,
  type Bucket,
  type PublicAccessBlockConfiguration,
} from "@aws-sdk/client-s3";
import { S3ControlClient, GetPublicAccessBlockCommand as GetAccountPublicAccessBlockCommand } from "@aws-sdk/client-s3-control";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";

const BPA_KEYS = ["BlockPublicAcls", "IgnorePublicAcls", "BlockPublicPolicy", "RestrictPublicBuckets"] as const;
type Bpa = Record<(typeof BPA_KEYS)[number], boolean>;
const NONE: Bpa = { BlockPublicAcls: false, IgnorePublicAcls: false, BlockPublicPolicy: false, RestrictPublicBuckets: false };
const PUBLIC_GROUPS: Record<string, string> = {
  "http://acs.amazonaws.com/groups/global/AllUsers": "AllUsers",
  "http://acs.amazonaws.com/groups/global/AuthenticatedUsers": "AuthenticatedUsers",
};

const toBpa = (c?: PublicAccessBlockConfiguration): Bpa => ({
  BlockPublicAcls: c?.BlockPublicAcls ?? false,
  IgnorePublicAcls: c?.IgnorePublicAcls ?? false,
  BlockPublicPolicy: c?.BlockPublicPolicy ?? false,
  RestrictPublicBuckets: c?.RestrictPublicBuckets ?? false,
});
const errName = (err: unknown) => (err instanceof Error ? err.name : String(err));

const clients = new Map<string, S3Client>();
const s3For = (region: string): S3Client => {
  if (!clients.has(region)) clients.set(region, new S3Client({ region, followRegionRedirects: true }));
  return clients.get(region)!;
};

async function accountBpa(accountId: string): Promise<Bpa> {
  try {
    const res = await new S3ControlClient({ region: "us-east-1" }).send(
      new GetAccountPublicAccessBlockCommand({ AccountId: accountId }),
    );
    return toBpa(res.PublicAccessBlockConfiguration);
  } catch (err) {
    if (errName(err) === "NoSuchPublicAccessBlockConfiguration") return NONE; // never configured
    throw err;
  }
}

type Row = { bucket: string; region: string; category: string; effectiveBpa: string; details: string };

async function checkBucket(b: Bucket, account: Bpa): Promise<Row> {
  const name = b.Name ?? "";
  const region = b.BucketRegion ?? "us-east-1";
  const s3 = s3For(region);
  const reasons: string[] = []; // grants that make the bucket public right now
  const blockedBy: string[] = []; // public grants that Block Public Access neutralizes
  const errors: string[] = []; // checks we could not read

  // Bucket-level Block Public Access. Effective setting = account OR bucket.
  let bucketBpa = NONE;
  try {
    bucketBpa = toBpa((await s3.send(new GetPublicAccessBlockCommand({ Bucket: name }))).PublicAccessBlockConfiguration);
  } catch (err) {
    if (errName(err) !== "NoSuchPublicAccessBlockConfiguration") errors.push(`BPA: ${errName(err)}`);
  }
  const eff = (k: keyof Bpa): boolean => account[k] || bucketBpa[k];

  // Bucket policy status: S3 evaluates the policy and says whether it grants public access.
  let policyPublic = false;
  try {
    policyPublic = (await s3.send(new GetBucketPolicyStatusCommand({ Bucket: name }))).PolicyStatus?.IsPublic ?? false;
  } catch (err) {
    if (errName(err) !== "NoSuchBucketPolicy") errors.push(`policy status: ${errName(err)}`);
  }
  if (policyPublic) (eff("RestrictPublicBuckets") ? blockedBy : reasons).push("public bucket policy");

  // ACL grants to the global AllUsers / AuthenticatedUsers groups.
  try {
    const acl = await s3.send(new GetBucketAclCommand({ Bucket: name }));
    for (const g of acl.Grants ?? []) {
      const group = PUBLIC_GROUPS[g.Grantee?.URI ?? ""];
      if (!group) continue;
      (eff("IgnorePublicAcls") ? blockedBy : reasons).push(`ACL ${g.Permission} to ${group}`);
    }
  } catch (err) {
    errors.push(`ACL: ${errName(err)}`);
  }

  const category =
    reasons.length > 0 ? "PUBLIC" : errors.length > 0 ? "UNKNOWN" : blockedBy.length > 0 ? "BLOCKED" : "PRIVATE";
  const on = BPA_KEYS.filter(eff);
  return {
    bucket: name,
    region,
    category,
    effectiveBpa: on.length === 4 ? "all 4 on" : on.join(",") || "off",
    details: [...reasons, ...blockedBy.map((r) => `${r} (blocked by BPA)`), ...errors].join("; "),
  };
}

async function main(): Promise<void> {
  const { Account } = await new STSClient({}).send(new GetCallerIdentityCommand({}));
  if (!Account) throw new Error("Could not determine the AWS account ID");
  const account = await accountBpa(Account);
  console.log(`Account ${Account} Block Public Access: ${JSON.stringify(account)}`);

  const buckets: Bucket[] = [];
  for await (const page of paginateListBuckets({ client: s3For("us-east-1") }, {})) {
    buckets.push(...(page.Buckets ?? []));
  }

  const rows: Row[] = [];
  for (let i = 0; i < buckets.length; i += 10) {
    rows.push(...(await Promise.all(buckets.slice(i, i + 10).map((b) => checkBucket(b, account)))));
  }
  const rank: Record<string, number> = { PUBLIC: 0, UNKNOWN: 1, BLOCKED: 2, PRIVATE: 3 };
  rows.sort((a, b) => rank[a.category] - rank[b.category] || a.bucket.localeCompare(b.bucket));

  if (process.argv.includes("--json")) {
    console.log(JSON.stringify(rows, null, 2));
    return;
  }
  console.table(rows.map((r) => ({ Bucket: r.bucket, Region: r.region, Access: r.category, "Effective BPA": r.effectiveBpa, Details: r.details })));
  const n = (c: string) => rows.filter((r) => r.category === c).length;
  console.log(`${rows.length} buckets: ${n("PUBLIC")} public, ${n("BLOCKED")} blocked, ${n("PRIVATE")} private, ${n("UNKNOWN")} unknown`);
}

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

How do you run it?

Terminal

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

AWS_PROFILE=readonly npx tsx s3-public-buckets.ts

# Only the public ones, as JSON
AWS_PROFILE=readonly npx tsx s3-public-buckets.ts --json | jq '.[] | select(.category == "PUBLIC")'

Sample output

Output (illustrative)

Account 123456789012 Block Public Access: {"BlockPublicAcls":false,"IgnorePublicAcls":false,"BlockPublicPolicy":false,"RestrictPublicBuckets":false}
┌─────────┬────────────────────────┬─────────────┬───────────┬────────────────────────────────────┬─────────────────────────────────────────┐
│ (index) │ Bucket                 │ Region      │ Access    │ Effective BPA                      │ Details                                 │
├─────────┼────────────────────────┼─────────────┼───────────┼────────────────────────────────────┼─────────────────────────────────────────┤
│ 0       │ 'acme-static-site'     │ 'us-east-1' │ 'PUBLIC'  │ 'off'                              │ 'public bucket policy'                  │
│ 1       │ 'acme-partner-drop'    │ 'eu-west-1' │ 'PUBLIC'  │ 'BlockPublicPolicy'                │ 'ACL WRITE to AuthenticatedUsers'       │
│ 2       │ 'acme-payroll-exports' │ 'us-east-1' │ 'UNKNOWN' │ 'all 4 on'                         │ 'ACL: AccessDenied'                     │
│ 3       │ 'acme-legacy-assets'   │ 'us-east-1' │ 'BLOCKED' │ 'BlockPublicAcls,IgnorePublicAcls' │ 'ACL READ to AllUsers (blocked by BPA)' │
│ 4       │ 'acme-app-uploads'     │ 'eu-west-1' │ 'PRIVATE' │ 'all 4 on'                         │ ''                                      │
│ 5       │ 'acme-terraform-state' │ 'us-east-1' │ 'PRIVATE' │ 'all 4 on'                         │ ''                                      │
└─────────┴────────────────────────┴─────────────┴───────────┴────────────────────────────────────┴─────────────────────────────────────────┘
6 buckets: 2 public, 1 blocked, 2 private, 1 unknown

Account ID, bucket names and findings are illustrative. acme-legacy-assets shows why ACL-only scripts mislead: its AllUsers grant is real but ignored, while acme-static-site is public through a policy an ACL check would never see.

What this check doesn’t cover

  • Object ACLs. Individual objects can carry public ACLs even when the bucket ACL is private. IgnorePublicAcls neutralizes those too, which is one reason to turn it on account-wide. Better still, turn ACLs off altogether with the script to find S3 buckets that still use ACLs and switch them to Bucket owner enforced.
  • Access points and cross-account grants. A policy that grants another specific AWS account isn’t “public” by S3’s definition, but it’s still external access. IAM Access Analyzer reports both.
  • Presigned URLs and CloudFront. Anyone with a presigned URL can read the object until it expires, and a CloudFront distribution can serve a private bucket to the world. For sharing single files, create a presigned S3 download URL with SDK v3 rather than opening the bucket.

Since April 2023, new buckets get Block Public Access switched on and ACLs disabled by default, so public buckets you find are usually older or were opened deliberately. The CIS Amazon Web Services Benchmarks include S3 Block Public Access among their storage recommendations, which makes this report useful evidence for an audit. The same benchmark also asks for bucket policies that deny plain HTTP; the script to find S3 buckets whose policy doesn’t require HTTPS checks that control. Access settings don’t tell you what a bucket holds; to find buckets with personal or financial data, check that Amazon Macie is enabled in every Region and read its bucket statistics.

Troubleshooting

  • UNKNOWN with AccessDenied. The bucket policy denies your audit role, or the profile lacks one of the actions. The guide to troubleshoot AWS IAM access denied errors helps you find which layer said no.
  • Account BPA shows all false but the console says “On”. Check you’re in the right account: the console shows the account you’re signed in to, the script the account of AWS_PROFILE.
  • A bucket is PUBLIC on purpose (a static website). Document it, tag it, and consider serving it through CloudFront with origin access control so the bucket itself can be private.

Ask ChatWithCloud instead

You can ask ChatWithCloud “Which S3 buckets are public, and why?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the result; the guide to ask AI which S3 buckets are largest and which are public shows that kind of session. Because the model writes the code each time, it may check fewer layers than this script, so ask it explicitly about Block Public Access and policy status. It runs changes without a confirmation step: use a read-only profile, and read the ChatWithCloud security model to see what’s sent for processing.

Frequently asked questions

How do I find public S3 buckets in the AWS console?

The S3 bucket list has an Access column that shows “Public” or “Objects can be public”. IAM Access Analyzer for S3 lists buckets shared publicly or with other accounts.

Is a bucket public if only AuthenticatedUsers has access?

Yes, in practice. AuthenticatedUsers means any AWS account in the world, not just your users, and S3 treats it as public.

Does Block Public Access break a static website bucket?

Yes, RestrictPublicBuckets stops anonymous reads through the policy. Serve the site through CloudFront with origin access control and keep the bucket private.

Should I turn on Block Public Access at the account level?

For most accounts, yes. Turn it on for accounts that never need public buckets, and keep genuinely public content in a separate account.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud