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:
- An explicit Deny anywhere wins, always.
- Otherwise an explicit Allow grants.
- 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
| Rule | Bucket policy | Identity policy | SCP |
|---|---|---|---|
| HTTPS only | Yes | No | Also, org-wide |
| Encryption required | Yes | No | Also, org-wide |
| Who may read this bucket | Yes | Yes, both needed cross-account | No |
| What one team may do | No | Yes | No |
| “No public buckets, ever” | No | No | Yes |
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:SecureTransportis always present, so the HTTPS Deny works on anonymous requests. Most condition keys are not.- A Deny on
s3:x-amz-server-side-encryptiondoes nothing on its own since S3 started encrypting by default in January 2023. Add aNullcondition. - CloudFront OAC without an
AWS:SourceArncondition grants read to every CloudFront distribution in AWS. - Cross-account needs a grant on both sides, and naming
:rootis far broader than naming the role. - A VPC endpoint Deny with the
Nullcondition 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.