If you manage S3 storage on AWS, most of your production buckets need one of six bucket policies, and each has a working example below. A bucket policy is a JSON document attached to the bucket that says who may do what with it and its objects. The six force encrypted connections (TLS, Transport Layer Security), let one CloudFront distribution read the bucket, and grant access to another AWS account. CloudFront is the AWS content delivery network. The others limit access to a VPC endpoint, a private path between your virtual private cloud and S3. They also require your KMS (Key Management Service) key on upload, or serve a public static site.
Two rules keep you out of trouble. Test every Deny against your own admin access before you rely on it, because a wrong deny locks you out too. For cross-account access, the other account must also grant its own identities S3 permissions, since the bucket policy covers only half of the grant.
What’s happening
A bucket policy is a resource policy: it grants or denies access to this bucket regardless of what the caller’s own IAM policy says — with one asymmetry that matters. Allow statements are additive with IAM policies for same-account callers, but a Deny statement beats everything, including the account root for most practical purposes. That is why lockouts happen: a deny with a condition you got backwards applies to you too, and unlike IAM policies you cannot fix it with a second policy — you have to remove it (as root, via delete-bucket-policy, or with an AWS Support case in the worst case).
The other structural fact: object actions and bucket actions target different ARNs. s3:GetObject applies to arn:aws:s3:::bucket/*, s3:ListBucket to arn:aws:s3:::bucket. Every policy below that touches both uses both ARNs; policies that skip one fail in confusing ways. The reference for every action and condition key is at https://docs.aws.amazon.com/AmazonS3/ — worth having open while you write.
What to check first
- Block Public Access status —
BlockPublicPolicywill reject a policy S3 considers public, andRestrictPublicBucketschanges how an existing public policy behaves. - Object Ownership — with Bucket owner enforced (the default for new buckets), ACLs are dead and the policy is the whole story; that is the mode you want.
- Who else already has access via IAM identity policies — a bucket policy is not a full inventory of access.
- Whether an SCP already enforces what you are about to duplicate — TLS and encryption denies often belong at the organization level.
How to verify
# Current policy, readable
aws s3api get-bucket-policy --bucket my-bucket \
--query Policy --output text | python3 -m json.tool
# Is S3 treating this bucket as public?
aws s3api get-bucket-policy-status --bucket my-bucket
# Apply a policy from a file
aws s3api put-bucket-policy --bucket my-bucket --policy file://policy.json
In the console: S3 > Buckets > your-bucket > Permissions > Bucket policy. After applying, test with a real non-admin identity, not just your own session.
The runbook
- Enforce TLS (put this on everything). Denies any request over plain HTTP. Safe: it does not restrict who, only how.
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
"Condition": {"Bool": {"aws:SecureTransport": "false"}}
}
- Allow CloudFront (Origin Access Control). The modern pattern uses the CloudFront service principal scoped to one distribution — no OAI, no public bucket:
{
"Sid": "AllowCloudFrontOAC",
"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/EDFDVBD6EXAMPLE"
}}
}
- Cross-account read/write. Grant the other account’s root (delegating to their IAM) or a specific role. Remember: the other account must also grant its identities S3 permissions — the bucket policy alone does nothing for them.
{
"Sid": "PartnerAccountAccess",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::444455556666:role/data-exchange"},
"Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"]
}
Keep Bucket owner enforced on, so objects they upload belong to you.
- Lock the bucket to a VPC endpoint. For buckets that should only ever be touched from inside your VPC — see gateway endpoints for S3 for the network half:
{
"Sid": "DenyOutsideVpce",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
"Condition": {"StringNotEquals": {"aws:SourceVpce": "vpce-0abcd1234efgh5678"}}
}
This is the classic lockout policy: it blocks the console and your laptop too. Carve out an admin role with a second condition (ArnNotLike on aws:PrincipalArn) before applying.
- Require SSE-KMS on upload. Denies PUTs that do not request your key. Note that with bucket default encryption set to SSE-KMS, unencrypted PUTs are already encrypted at rest — this policy is for when you must guarantee a specific key:
{
"Sid": "DenyWrongEncryption",
"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:ca-central-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"
}}
}
- Public static site read. Only after deliberately relaxing Block Public Access on that one bucket, and only for truly public content:
{
"Sid": "PublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-site-bucket/*"
}
In 2026, prefer pattern 2 (CloudFront + OAC) over this one — you get TLS, caching, and no public bucket.
When this is the wrong move
Bucket policies are the wrong tool for per-user or per-tenant authorization inside an application — that belongs in your application layer with presigned URLs or scoped session credentials, not in a policy document that maxes out at 20 KB. And if you find yourself granting the same partner account on fifteen buckets, the pattern probably wants S3 Access Points or a dedicated role instead of fifteen policies.
Common mistakes
- Writing a
DenywithNotPrincipalor a negated condition, testing nothing, and locking out every identity including your own. - Granting
s3:GetObjectbut forgettings3:ListBucket, so clients that list-then-fetch fail on the first step. - Assuming the bucket policy grant is sufficient for a cross-account caller whose own IAM policy never mentions S3.
- Using
aws:SourceIpconditions against traffic that actually arrives via a VPC endpoint — private IPs never match public CIDR ranges, so the deny fires on everyone.
Prevention
- Keep policies in version control and apply through IaC, so a bad deny is a
git revertaway. - Test every new deny statement against a scratch bucket first, with your own role, before it touches production.
- Run IAM Access Analyzer after each change — it reads the policy the way AWS evaluates it, not the way you meant it.
- Standardize statements 1 and 5 as an organization-wide baseline instead of copy-pasting per bucket.
Six patterns, two ARNs, one rule about untested denies — that genuinely covers most of what production buckets need. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.