S3 bucket policy examples that work, and the guardrail each one is missing

17 August 2026

AWS S3 Bucket Policy Generator Pick a pattern, get a safe policy. Runs in your browser.

Most bucket policy examples on the internet were written before three things changed: Block Public Access became the default, ACLs were turned off, and S3 started encrypting every object whether you asked or not. The third one silently breaks the most widely copied policy of all, the Deny that enforces SSE-KMS.

Here are six patterns that work today, each with the guardrail it is usually missing.

1. HTTPS only

The one to put on every bucket. It costs nothing and closes a real hole.

{
  "Sid": "DenyInsecureTransport",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": [
    "arn:aws:s3:::my-bucket",
    "arn:aws:s3:::my-bucket/*"
  ],
  "Condition": {
    "Bool": { "aws:SecureTransport": "false" }
  }
}

Both ARNs. The bucket and the objects are different resources and s3:ListBucket operates on the first while s3:GetObject operates on the second. A policy with only the /* form leaves bucket-level actions unprotected, and this is the single most common defect in real bucket policies.

aws:SecureTransport is a boolean that is always present, so unlike most condition keys this Deny works on anonymous requests too. That is unusual and it is why this pattern is safe to write as a Deny with a negation.

2. Enforce SSE-KMS, correctly

The widely copied version:

{
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:PutObject",
  "Resource": "arn:aws:s3:::my-bucket/*",
  "Condition": {
    "StringNotEquals": { "s3:x-amz-server-side-encryption": "aws:kms" }
  }
}

Since January 2023, S3 encrypts every new object with SSE-S3 by default. A client that sends no encryption header gets an encrypted object and sends no s3:x-amz-server-side-encryption value. Under StringNotEquals, an absent key does not match, so the condition is false, so the Deny does not apply. The policy written to require KMS permits every upload that does not mention encryption, which after 2023 is most of them.

The working version needs both halves:

[
  {
    "Sid": "DenyMissingEncryptionHeader",
    "Effect": "Deny",
    "Principal": "*",
    "Action": "s3:PutObject",
    "Resource": "arn:aws:s3:::my-bucket/*",
    "Condition": {
      "Null": { "s3:x-amz-server-side-encryption": "true" }
    }
  },
  {
    "Sid": "DenyWrongEncryptionAlgorithm",
    "Effect": "Deny",
    "Principal": "*",
    "Action": "s3:PutObject",
    "Resource": "arn:aws:s3:::my-bucket/*",
    "Condition": {
      "StringNotEquals": {
        "s3:x-amz-server-side-encryption": "aws:kms"
      }
    }
  },
  {
    "Sid": "DenyWrongKey",
    "Effect": "Deny",
    "Principal": "*",
    "Action": "s3:PutObject",
    "Resource": "arn:aws:s3:::my-bucket/*",
    "Condition": {
      "StringNotEquals": {
        "s3:x-amz-server-side-encryption-aws-kms-key-id":
          "arn:aws:kms:eu-west-1:111122223333:key/abcd-1234"
      }
    }
  }
]

Null set to "true" means “this key is absent from the request”, which is the statement that catches a client sending no header at all.

These have to be separate statements. Conditions inside a single block combine with AND, so putting the Null test next to the StringNotEquals would deny only requests that are both missing the header and setting it to something other than KMS, which is no request at all. “No header” and “the wrong algorithm” are two different uploads and each needs its own Deny.

Then set S3 Bucket Keys on the bucket. Without them, SSE-KMS makes a KMS API call per object, which shows up as both latency and a KMS bill that surprises people at volume.

3. CloudFront, with origin access control

OAC replaced OAI. If your policy contains the string CloudFront Origin Access Identity, it is the legacy form:

{
  "Sid": "AllowCloudFrontServicePrincipal",
  "Effect": "Allow",
  "Principal": { "Service": "cloudfront.amazonaws.com" },
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::my-bucket/*",
  "Condition": {
    "StringEquals": {
      "AWS:SourceArn":
        "arn:aws:cloudfront::111122223333:distribution/E1ABCDEFGHIJKL"
    }
  }
}

The AWS:SourceArn condition is the whole security of this pattern. Without it you have granted the CloudFront service principal read access, and the CloudFront service principal is shared by every CloudFront distribution in every AWS account. Anyone could point a distribution at your bucket. This is the confused deputy problem, and the condition is what closes it.

Keep Block Public Access fully on with this pattern. That is the point of it: the bucket is private and only the distribution can read it.

4. Cross-account read

{
  "Sid": "AllowPartnerAccountRead",
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::444455556666:role/partner-reader" },
  "Action": ["s3:GetObject", "s3:ListBucket"],
  "Resource": [
    "arn:aws:s3:::my-bucket",
    "arn:aws:s3:::my-bucket/*"
  ]
}

Three things that are not obvious:

A bucket policy alone is not enough. Cross-account access needs a grant on both sides. The other account’s identity policy must also allow the action. Neither one grants by itself, and the failure is an AccessDenied that looks like a bucket policy problem from your side.

Name the role, not the account root. arn:aws:iam::444455556666:root means every principal in that account that has a matching identity policy. It is the form most examples use and it is far broader than intended.

Uploads are different from reads. If the partner writes objects, decide who owns them. With Object Ownership set to bucket owner enforced, which has been the default for new buckets since April 2023, you own everything written to your bucket and ACLs are ignored. On an older bucket the writing account owns the object and you can be unable to read your own data.

For anything organisation-wide, aws:PrincipalOrgID is better than listing account ids, because it does not need updating when an account is added.

5. VPC endpoint only

{
  "Sid": "DenyOutsideVpce",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": [
    "arn:aws:s3:::my-bucket",
    "arn:aws:s3:::my-bucket/*"
  ],
  "Condition": {
    "StringNotEqualsIfExists": { "aws:SourceVpce": "vpce-0abc123def456" }
  }
}

IfExists is the whole difference here. With a plain StringNotEquals, the absent-key rule applies: a request that did not come through any VPC endpoint carries no aws:SourceVpce, the condition is false, and the Deny does not apply. The statement written to keep the internet out is the one the internet walks past.

StringNotEqualsIfExists evaluates true when the key is absent, so the Deny applies to a request from a different endpoint and to a request from no endpoint alike. That is the behaviour people believe they are getting from the plain form.

Read this one carefully before deploying it, because it is also the pattern most likely to lock you out: the stricter version denies the console session you would use to undo it.

Use aws:SourceVpc instead if you want any endpoint in a given VPC rather than one specific endpoint. And note that aws:SourceIp does not do this job: traffic through a gateway endpoint has no public source IP, which is the third failure mode in the bucket policy that denies nothing.

Always leave yourself a break-glass path. An ArnLike exemption on a break-glass role, or an aws:PrincipalArn condition naming your admin role, is the difference between a tight policy and an incident.

6. Public read, when you actually mean it

{
  "Sid": "PublicReadForStaticSite",
  "Effect": "Allow",
  "Principal": "*",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::my-public-assets/*"
}

This does nothing while Block Public Access is on, and Block Public Access is on by default for every bucket created since April 2023. You have to turn it off deliberately, and the policy sits there granting nothing until somebody does.

That is worth knowing in both directions. It is why so many alarming-looking policies are harmless, and it is why the harmless-looking ones become an incident the day an unrelated change flips the account setting.

For a static site, prefer pattern 3. CloudFront with OAC gives you TLS, a cache, and a bucket that is never public. Direct public read on a bucket is correct for a small number of genuinely open datasets and almost nothing else.

Note the Action is s3:GetObject only. Adding s3:ListBucket to a public policy makes your object keys enumerable, which turns “you need to know the URL” into “here is the index”.

The wrong instinct: add another Deny

When a policy is not restricting what it should, the reflex is another Deny statement. It usually makes the document harder to reason about without closing anything, because the new statement carries the same absent-key or literal-match problem as the first.

Evaluation order explains why:

  1. An explicit Deny anywhere wins, always.
  2. Otherwise an explicit Allow grants.
  3. Otherwise, implicit deny.

A Deny that does not match contributes nothing, and stacking more of them adds statements that also do not match. The reliable structure is a small number of narrow Allows plus one well-tested Deny for the guardrails you cannot express as an absence.

And the bucket policy is one input among several. The effective permission is the intersection of the identity policy, the bucket policy, any permissions boundary, any SCP, and Block Public Access. Reading one document tells you what it permits, not what is possible.

Where to put each rule

RuleBucket policyIdentity policySCP
HTTPS onlyYesNoAlso, org-wide
Encryption requiredYesNoAlso, org-wide
Who may read this bucketYesYes, both needed cross-accountNo
What one team may doNoYesNo
“No public buckets, ever”NoNoYes

The last row is the one people get wrong. A bucket policy is editable by whoever owns the bucket, so it cannot enforce a rule against that person. Organisational guardrails belong in an SCP or in Block Public Access at the account level, where the bucket owner cannot remove them.

When a permissive policy is fine

A genuinely public dataset is genuinely public. Open data, public artefacts, a static site’s assets. The risk of public read on a bucket containing only things you published is not zero, but it is bounded by what you put in it. Keep that bucket separate from everything else and the decision stays simple.

A wide policy behind a tight network boundary is a reasonable trade for internal tooling, as long as it is a deliberate trade and not the result of nobody wanting to work out the narrow version.

Do not add conditions you cannot test. An unverified aws:SourceIp list maintained by hand across three environments will eventually deny production and allow a laptop. A policy nobody understands is not a control.

The short version

  • Always both ARNs. Bucket-level actions need the bucket ARN, object-level need /*.
  • aws:SecureTransport is always present, so the HTTPS Deny works on anonymous requests. Most condition keys are not.
  • A Deny on s3:x-amz-server-side-encryption does nothing on its own since S3 started encrypting by default in January 2023. Add a Null condition.
  • CloudFront OAC without an AWS:SourceArn condition grants read to every CloudFront distribution in AWS.
  • Cross-account needs a grant on both sides, and naming :root is far broader than naming the role.
  • A VPC endpoint Deny with the Null condition inverted locks you out of your own bucket.
  • Public read grants nothing while Block Public Access is on, which is the default since April 2023.
  • Conditions inside one block combine with AND. Different failures need different statements.

The S3 bucket policy generator produces these patterns with the guardrails attached: HTTPS-only, SSE-KMS with the Null condition, VPC endpoint and organisation scoping, for private, CloudFront, cross-account and public-read buckets. Every policy it emits is run back through the bucket policy analyzer on the way out, so the answer to “is this bucket public” is computed rather than claimed. Both run entirely in your browser.