Find S3 Buckets Whose Policy Doesn’t Require HTTPS

Glowing fiber optic cable strands

Photo by Compare Fibre on Unsplash

An S3 bucket policy that requires HTTPS has a statement with "Effect": "Deny", "Principal": "*", "Action": "s3:*", both the bucket ARN and bucket/* as resources, and the condition "Bool": {"aws:SecureTransport": "false"}. To find buckets without it, read each policy with GetBucketPolicy and look for that statement; a missing policy returns NoSuchBucketPolicy.

Amazon S3 accepts both HTTP and HTTPS. The AWS SDKs and CLI use HTTPS by default, so most traffic is encrypted anyway, but “most” isn’t a control: an old script with an http:// endpoint, a misconfigured proxy or a hand-written client can still move data in plaintext. A bucket policy that requires HTTPS makes S3 reject those requests. This example is for engineers who need to check every bucket for that statement and add it where it’s missing without breaking the rest of the policy.

The TypeScript script uses the AWS SDK for JavaScript v3. It reports by default and changes a policy only when you pass --apply, in the same style as the example to find public and private S3 buckets with the AWS SDK, which covers who can read a bucket rather than how they connect.

The S3 bucket policy statement that requires HTTPS

This is the statement the script looks for and, with --apply, adds. Replace the bucket name:

deny-insecure-transport.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::amzn-s3-demo-bucket",
        "arn:aws:s3:::amzn-s3-demo-bucket/*"
      ],
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        }
      }
    }
  ]
}

Each piece matters. Deny overrides any Allow, so no IAM policy can grant plain-HTTP access. Principal: "*" covers your own roles too. Bucket-level operations such as listing are authorized against the bucket ARN, object operations against bucket/*, so a statement with only one of them leaves half the API open over HTTP. The requirement appears as control 2.1.1 in versions 3.0.0 and 5.0.0 of the CIS Amazon Web Services Foundations Benchmark, and AWS checks it with the Security Hub control S3.5 and the Config rule s3-bucket-ssl-requests-only.

Why doesn’t a TLS version rule block HTTP?

Some policies deny requests with NumericLessThan on s3:TlsVersion instead. A plain-HTTP request has no TLS version, and a condition on a key that isn’t in the request doesn’t match, so the deny never applies. The script reports those buckets as MISSING with a note. Use both statements if you want a version floor: NIST SP 800-52 Rev. 2 requires TLS 1.2 for US government systems, and S3 supports TLS 1.2 and 1.3 on all its API endpoints.

What does the script check?

  1. Lists bucketsListBuckets with the paginator; each entry includes BucketRegion, so the next calls go to the right Region.
  2. Reads the policyGetBucketPolicy. NoSuchBucketPolicy means no policy at all.
  3. Evaluates each Deny statementok when one statement covers all actions, both ARNs and aws:SecureTransport false; PARTIAL when it covers only some actions or one ARN; MISSING otherwise.
  4. Skips website bucketsGetBucketWebsite succeeds for buckets with static website hosting. S3 website endpoints only support HTTP, so a SecureTransport deny would match every website request. Those rows say SKIP; serve them over HTTPS through CloudFront first.
  5. Adds the statement on requestWith --apply, it appends DenyInsecureTransport to the existing statements and calls PutBucketPolicy.

Prerequisites

Which IAM permissions does it need?

The second statement is only for --apply. Leave it out of an audit role.

s3-https-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListBuckets",
      "Effect": "Allow",
      "Action": "s3:ListAllMyBuckets",
      "Resource": "*"
    },
    {
      "Sid": "ReadBucketPolicyAndWebsite",
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketPolicy",
        "s3:GetBucketWebsite"
      ],
      "Resource": "arn:aws:s3:::*"
    },
    {
      "Sid": "AddHttpsStatementWithApply",
      "Effect": "Allow",
      "Action": "s3:PutBucketPolicy",
      "Resource": "arn:aws:s3:::*"
    }
  ]
}

The guide to review an IAM policy for least privilege shows how to narrow arn:aws:s3:::* to named buckets.

The script

find-s3-buckets-without-https-enforcement.ts

// find-s3-buckets-without-https-enforcement.ts
// Checks every S3 general purpose bucket's policy for a statement that denies requests made without
// TLS: Effect Deny, Principal *, all S3 actions, the bucket and its objects, and the condition
// aws:SecureTransport = false. Report-only by default. --apply adds that statement to the existing
// policy (or creates one) on the buckets that lack it. Buckets with static website hosting are skipped.
// Usage: npx tsx find-s3-buckets-without-https-enforcement.ts [--buckets=a,b] [--apply]
import {
  S3Client,
  GetBucketPolicyCommand,
  GetBucketWebsiteCommand,
  PutBucketPolicyCommand,
  paginateListBuckets,
} 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);
const SID = "DenyInsecureTransport";

interface Statement {
  Sid?: string;
  Effect?: string;
  Principal?: string | Record<string, string | string[]>;
  Action?: string | string[];
  Resource?: string | string[];
  Condition?: Record<string, Record<string, unknown>>;
}
interface Policy { Version?: string; Id?: string; Statement?: Statement | Statement[] }
interface Row { Bucket: string; Region: string; Policy: string; Status: string; Detail: string }

const list = (v: string | string[] | undefined): string[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
const statementsOf = (p: Policy | undefined): Statement[] =>
  p?.Statement === undefined ? [] : Array.isArray(p.Statement) ? p.Statement : [p.Statement];
const errName = (err: unknown): string => (err instanceof Error ? err.name : String(err));

function everyone(p: Statement["Principal"]): boolean {
  if (p === "*") return true;
  return typeof p === "object" && p !== null && list(p.AWS).includes("*");
}

// Finds a condition like {"Bool": {"aws:SecureTransport": "false"}} (operator and key are case-insensitive).
function deniesPlainHttp(cond: Statement["Condition"]): boolean {
  for (const [op, block] of Object.entries(cond ?? {})) {
    if (!/^bool(ifexists)?$/i.test(op)) continue;
    for (const [key, val] of Object.entries(block)) {
      const vals = Array.isArray(val) ? val : [val];
      if (key.toLowerCase() === "aws:securetransport" && vals.some((v) => String(v).toLowerCase() === "false")) return true;
    }
  }
  return false;
}
const usesTlsVersion = (cond: Statement["Condition"]): boolean =>
  Object.values(cond ?? {}).some((block) => Object.keys(block).some((k) => k.toLowerCase() === "s3:tlsversion"));

function evaluate(bucket: string, statements: Statement[]): { status: string; detail: string } {
  const bucketArn = `arn:aws:s3:::${bucket}`;
  let partial = "";
  let tlsVersionOnly = false;
  for (const s of statements) {
    if (s.Effect !== "Deny" || !everyone(s.Principal)) continue;
    if (usesTlsVersion(s.Condition) && !deniesPlainHttp(s.Condition)) tlsVersionOnly = true;
    if (!deniesPlainHttp(s.Condition)) continue;
    const actions = list(s.Action).map((a) => a.toLowerCase());
    const resources = list(s.Resource);
    const allActions = actions.includes("s3:*") || actions.includes("*");
    const coversBucket = resources.includes(bucketArn) || resources.includes("*");
    const coversObjects = resources.includes(`${bucketArn}/*`) || resources.includes("*");
    if (allActions && coversBucket && coversObjects) return { status: "ok", detail: `enforced by ${s.Sid ?? "unnamed statement"}` };
    partial = !allActions ? `only ${actions.join(", ")}` : !coversObjects ? "bucket ARN only, not objects" : "objects only, not the bucket ARN";
  }
  if (partial) return { status: "PARTIAL", detail: partial };
  if (tlsVersionOnly) return { status: "MISSING", detail: "s3:TlsVersion rule only; plain HTTP requests are not denied" };
  return { status: "MISSING", detail: "no Deny on aws:SecureTransport=false" };
}

function httpsStatement(bucket: string): Statement {
  return {
    Sid: SID,
    Effect: "Deny",
    Principal: "*",
    Action: "s3:*",
    Resource: [`arn:aws:s3:::${bucket}`, `arn:aws:s3:::${bucket}/*`],
    Condition: { Bool: { "aws:SecureTransport": "false" } },
  };
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  const policies = new Map<string, Policy | undefined>();
  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 policy: Policy | undefined;
      try {
        const out = await s3.send(new GetBucketPolicyCommand({ Bucket: bucket }));
        policy = JSON.parse(out.Policy ?? "{}") as Policy;
      } catch (err) {
        if (errName(err) !== "NoSuchBucketPolicy") {
          rows.push({ Bucket: bucket, Region: region, Policy: "?", Status: "ERROR", Detail: errName(err) });
          continue;
        }
      }
      let website = false;
      try {
        await s3.send(new GetBucketWebsiteCommand({ Bucket: bucket }));
        website = true;
      } catch {
        // NoSuchWebsiteConfiguration: not a website bucket
      }
      const { status, detail } = evaluate(bucket, statementsOf(policy));
      rows.push({
        Bucket: bucket,
        Region: region,
        Policy: policy ? "yes" : "none",
        Status: website && status !== "ok" ? "SKIP" : status,
        Detail: website && status !== "ok" ? "static website hosting (HTTP-only endpoint)" : detail,
      });
      policies.set(bucket, policy);
    }
  }

  console.table(rows);
  const todo = rows.filter((r) => r.Status === "MISSING" || r.Status === "PARTIAL");
  console.log(`${rows.length} bucket(s) checked; ${todo.length} without full HTTPS enforcement.`);
  if (!apply) {
    console.log(`Report only: nothing changed. --apply would add the ${SID} statement to ${todo.length} bucket policy(ies).`);
    if (todo.length) process.exitCode = 2;
    return;
  }

  for (const r of todo) {
    const current = policies.get(r.Bucket);
    const existing = statementsOf(current);
    if (existing.some((s) => s.Sid === SID)) {
      console.log(`${r.Bucket}: a statement named ${SID} already exists; edit it by hand`);
      continue;
    }
    // PutBucketPolicy replaces the whole policy, so keep every existing statement.
    const next: Policy = { ...current, Version: current?.Version ?? "2012-10-17", Statement: [...existing, httpsStatement(r.Bucket)] };
    try {
      await new S3Client({ region: r.Region }).send(new PutBucketPolicyCommand({ Bucket: r.Bucket, Policy: JSON.stringify(next) }));
      console.log(`${r.Bucket}: ${SID} added`);
    } 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-without-https-enforcement.ts

# Add the statement to two buckets only
AWS_PROFILE=storage-admin npx tsx find-s3-buckets-without-https-enforcement.ts --buckets=acme-cloudtrail-logs,acme-tmp-builds --apply

The report exits with code 2 when a bucket is MISSING or PARTIAL. Run --apply on named buckets first, not the whole account.

Sample output

Output

┌─────────┬────────────────────────┬─────────────┬────────┬───────────┬───────────────────────────────────────────────────────────────┐
│ (index) │ Bucket                 │ Region      │ Policy │ Status    │ Detail                                                        │
├─────────┼────────────────────────┼─────────────┼────────┼───────────┼───────────────────────────────────────────────────────────────┤
│ 0       │ 'acme-app-uploads'     │ 'us-east-1' │ 'yes'  │ 'ok'      │ 'enforced by DenyInsecureTransport'                           │
│ 1       │ 'acme-cloudtrail-logs' │ 'us-east-1' │ 'yes'  │ 'MISSING' │ 'no Deny on aws:SecureTransport=false'                        │
│ 2       │ 'acme-data-lake'       │ 'eu-west-1' │ 'yes'  │ 'PARTIAL' │ 'bucket ARN only, not objects'                                │
│ 3       │ 'acme-exports'         │ 'eu-west-1' │ 'yes'  │ 'MISSING' │ 's3:TlsVersion rule only; plain HTTP requests are not denied' │
│ 4       │ 'acme-marketing-site'  │ 'us-east-1' │ 'yes'  │ 'SKIP'    │ 'static website hosting (HTTP-only endpoint)'                 │
│ 5       │ 'acme-tmp-builds'      │ 'us-west-2' │ 'none' │ 'MISSING' │ 'no Deny on aws:SecureTransport=false'                        │
└─────────┴────────────────────────┴─────────────┴────────┴───────────┴───────────────────────────────────────────────────────────────┘
6 bucket(s) checked; 4 without full HTTPS enforcement.
Report only: nothing changed. --apply would add the DenyInsecureTransport statement to 4 bucket policy(ies).

The names are illustrative. acme-data-lake has a deny that lists only the bucket ARN, so object reads and writes still work over HTTP. acme-exports has a TLS version rule but nothing that stops plain HTTP. --apply leaves PARTIAL statements in place and adds a complete one next to them; remove the partial one afterwards to keep the policy readable.

What can break when you require HTTPS?

  • Clients that use http://. That’s the point, but find them first: S3 server access logs and CloudTrail record the TLS version of each request, so requests without one are your plain-HTTP clients.
  • Website hosting and CloudFront website origins. A CloudFront distribution that uses the S3 website endpoint as its origin connects over HTTP. The script skips website buckets for that reason.
  • A concurrent policy edit. PutBucketPolicy replaces the whole policy. The script reads and writes within seconds, but if Terraform or CloudFormation manages the policy, add the statement there instead or the next deploy removes it. The check that AWS Config is recording in every Region is a prerequisite for the s3-bucket-ssl-requests-only rule catching drift.

For an organization-wide rule, AWS also documents enforcing HTTPS with resource control policies (RCPs) and VPC endpoint policies, which saves editing every bucket. If you’re already editing bucket policies, also find S3 buckets that still use ACLs and disable them: grants migrated off ACLs end up in the same policy.

Troubleshooting

  • AccessDenied on GetBucketPolicy. The bucket policy itself may deny your role, or an SCP blocks the action. The guide to troubleshoot AWS IAM access denied errors covers both.
  • MalformedPolicy on --apply. S3 rejected the combined policy. Read the error message, fix the existing statements by hand and run the script again; it never drops or rewrites your statements.
  • A statement named DenyInsecureTransport already exists. The script won’t create a duplicate Sid. Edit that statement so it covers both ARNs and s3:*.

Transport security doesn’t stop at S3. The checks to check the CloudFront minimum TLS version on every distribution and find load balancers serving plain HTTP without a redirect cover the edges in front of your buckets.

Ask ChatWithCloud instead

For a spot check, ask ChatWithCloud “Which of my S3 buckets have no policy statement denying aws:SecureTransport false?” It writes AWS SDK for JavaScript v2 code, runs it with your profile and summarizes the result, much like the guide to ask AI which S3 buckets are largest and which are public. It runs generated code without asking first, so connect ChatWithCloud with a read-only AWS profile and keep policy changes in this script.

Frequently asked questions

How do I force HTTPS on an S3 bucket?

Add a Deny statement for all principals and s3:* on the bucket and bucket/*, with the condition "Bool": {"aws:SecureTransport": "false"}. There’s no bucket setting for it; the policy is the control.

Does S3 accept HTTP requests?

Yes. S3 supports both HTTP and HTTPS unless a policy denies requests where aws:SecureTransport is false. The AWS SDKs and CLI use HTTPS by default.

Will requiring HTTPS break my S3 static website?

It would, because S3 website endpoints only support HTTP. Put CloudFront with a REST API origin in front first, or leave the website bucket out.

Is aws:SecureTransport the same as s3:TlsVersion?

No. aws:SecureTransport tells whether TLS was used at all; s3:TlsVersion tells which version. Only the first one blocks plain HTTP.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud