Security9 min readSeptember 22, 2026

S3 bucket policy examples for giving a team access to the right files

How S3 decides whether to allow a request, the ARN mistake that breaks half of all bucket policies, and worked examples for prefix access, cross-account reads, TLS-only and encryption-required buckets.

JeVaughn Ferguson
Founder, developer
The short version

Write two statements, not one: bucket-level actions against the bucket ARN, object-level actions against bucket/*, and scope the listing with s3:prefix. Grant cross-account access to a named role or a fixed-value condition rather than an account root. Add the TLS and encryption denies, keep all four Block Public Access settings on, and remember that a bucket policy authorises principals — if you need to know which person opened a file, that answer has to come from a layer above the bucket.

A bucket policy is a JSON document attached to a bucket saying which principals may perform which actions on which resources, under which conditions. It is the most direct way to write down "this team reads these files and nothing else", and the most common way to accidentally write something much broader.

This guide covers how a request is evaluated, the resource ARN rule that breaks policies which look correct, five examples worth keeping, what Block Public Access does and does not stop, and where a bucket policy runs out of road.

How a request is actually evaluated

An S3 request is allowed only if something grants it and nothing denies it. An explicit Deny anywhere — in the bucket policy, in the caller’s IAM policy, in a service control policy, in a VPC endpoint policy — wins over every Allow. That asymmetry is what makes Deny statements the right tool for guardrails and the wrong tool for everyday permissions.

For same-account access, an Allow in either the bucket policy or the caller’s IAM policy is enough. For cross-account access, both are required: your bucket policy must allow the other account’s principal, and that account must separately grant its own role or user the permission. A cross-account policy that "looks right" and still returns AccessDenied is usually missing the second half, in an account you do not administer.

By default, S3 Object Ownership is set to Bucket owner enforced and ACLs are disabled, so the bucket owner owns every object in the bucket and access is managed entirely through policies. If you learned S3 when uploads from another account needed a bucket-owner-full-control ACL, that step is gone on buckets created with the default setting.

One limit is worth knowing before you rely on a policy as a safety net: you cannot use a bucket policy to prevent deletions or transitions by an S3 Lifecycle rule. Even a policy that denies every action to every principal leaves the lifecycle configuration working normally.

The ARN mistake that breaks half of all bucket policies

S3 has bucket-level actions and object-level actions, and they take different resource ARNs. Object actions such as s3:GetObject and s3:PutObject apply to arn:aws:s3:::bucket-name/*. Bucket actions such as s3:ListBucket and s3:GetBucketLocation apply to arn:aws:s3:::bucket-name, with no trailing slash and no wildcard.

A policy that grants s3:ListBucket on bucket-name/* grants nothing usable, because no bucket-level action ever matches that ARN. The symptom is a team who can open a file if you send them its exact key but cannot see a folder listing. Most working policies need two statements for this reason.

To scope a team to one prefix, constrain the listing with the s3:prefix condition and constrain the reads with the object statement’s Resource. The condition governs what they can enumerate; the Resource governs what they can actually fetch. You need both, because either one alone leaves a gap.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListOnlyTheFinancePrefix",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::123456789012:role/FinanceReaders" },
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket",
      "Condition": {
        "StringLike": { "s3:prefix": ["finance/", "finance/*"] }
      }
    },
    {
      "Sid": "ReadObjectsUnderTheFinancePrefix",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::123456789012:role/FinanceReaders" },
      "Action": ["s3:GetObject", "s3:GetObjectVersion"],
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/finance/*"
    }
  ]
}

Cross-account access, written narrowly

Naming an account root as the principal — arn:aws:iam::123456789012:root — delegates the decision to that account’s administrators, who can then hand the permission to anyone they like. Naming a specific role ARN keeps the decision with you. Prefer the role, and prefer a role you have seen.

Inside an AWS Organization, the aws:PrincipalOrgID condition is usually the better shape: it allows any principal in your organization without you maintaining a list of account IDs that goes stale every time someone creates an account. Pair it with a principal of "*" only when the condition is doing the real work, and read the Block Public Access section below before you do, because "*" with a fixed-value condition is evaluated differently from "*" alone.

{
  "Sid": "ReadOnlyForAnyPrincipalInOurOrganisation",
  "Effect": "Allow",
  "Principal": { "AWS": "*" },
  "Action": ["s3:GetObject", "s3:ListBucket"],
  "Resource": [
    "arn:aws:s3:::amzn-s3-demo-bucket",
    "arn:aws:s3:::amzn-s3-demo-bucket/*"
  ],
  "Condition": {
    "StringEquals": { "aws:PrincipalOrgID": "o-abc123example" }
  }
}

Conditions that turn a policy into a control

Three Deny statements are worth adding to almost any bucket holding business documents. They do not grant anything, so they compose safely with whatever allows already exist, and an explicit Deny cannot be undone by a later Allow.

The first refuses any request that did not arrive over TLS. The second refuses uploads that do not ask for the encryption you require, which is what stops a well-meaning script from writing an unencrypted object into an otherwise careful bucket. The third caps the age of a presigned URL signature, so a link that leaks a week later is already dead regardless of the expiry someone chose when they signed it.

Network conditions such as aws:SourceIp and aws:SourceVpce are useful and easy to get wrong. A very broad CIDR is treated as public by Block Public Access — anything wider than /8 for IPv4 or /32 for IPv6, outside the RFC 1918 private ranges — so a policy meant to restrict access can be rejected for being too permissive.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyUnencryptedTransport",
      "Effect": "Deny",
      "Principal": { "AWS": "*" },
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::amzn-s3-demo-bucket",
        "arn:aws:s3:::amzn-s3-demo-bucket/*"
      ],
      "Condition": { "Bool": { "aws:SecureTransport": "false" } }
    },
    {
      "Sid": "DenyUploadsWithoutKmsEncryption",
      "Effect": "Deny",
      "Principal": { "AWS": "*" },
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/*",
      "Condition": {
        "StringNotEquals": {
          "s3:x-amz-server-side-encryption": "aws:kms"
        }
      }
    },
    {
      "Sid": "DenyPresignedRequestsOlderThanTenMinutes",
      "Effect": "Deny",
      "Principal": { "AWS": "*" },
      "Action": "s3:*",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/*",
      "Condition": {
        "NumericGreaterThan": { "s3:signatureAge": "600000" }
      }
    }
  ]
}

What Block Public Access actually blocks

Block Public Access is four independent settings that can be applied to a bucket, an account, an access point or an organization, and S3 applies the most restrictive combination. New buckets have public access blocked by default.

BlockPublicAcls rejects requests that set a public ACL. IgnorePublicAcls ignores public ACLs that already exist. BlockPublicPolicy rejects PutBucketPolicy calls whose policy allows public access. RestrictPublicBuckets limits a bucket that already has a public policy to AWS service principals and principals in the owning account, which cuts off all other cross-account access.

The definition of "public" is stricter than most people assume. S3 starts by assuming a policy is public, then looks for a grant tied to a fixed value: a named principal, a fixed aws:PrincipalOrgID, aws:SourceArn, aws:SourceVpc, aws:SourceVpce, aws:SourceAccount, a narrow CIDR, and a short list of others. Anything relying on a wildcard or a policy variable stays public.

The consequence to internalise is that one public statement taints the whole policy. AWS documents the case directly: a policy granting access to CloudTrail, to a named second account, and to the public leaves only CloudTrail working once RestrictPublicBuckets is on, because the third statement makes the policy public and the setting then cuts the cross-account grant too. Remove the public statement and the named account gets its access back.

Where bucket policies stop being the right tool

A bucket policy answers "may this principal call this API on this object". It cannot answer "which person opened this contract, and when". Principals are roles and users; the people behind them are a layer the policy never sees, and CloudTrail will faithfully record the role.

The policy is also a single document per bucket with a size cap, which is fine for a handful of statements and unpleasant once you are adding one per team, per prefix, per quarter. Access points let you attach separate policies to separate named endpoints on the same bucket, and S3 Access Grants map directory identities to prefixes, which is the AWS-native answer to part of this.

The other answer is to stop expressing per-person access in AWS at all. BucketDesk connects to a bucket through one scoped IAM role deployed by CloudFormation in your account, and decides who may see which folder in the product, where the subject is a named person with a sign-in. The bucket policy then has one job — describe the role — and the questions your auditor actually asks are answered somewhere that knows people’s names.

A checklist before you save a bucket policy

  • Every bucket-level action has a bucket ARN and every object-level action has a /* ARN, in separate statements.
  • Cross-account grants name a role ARN, or a "*" principal narrowed by a fixed-value condition such as aws:PrincipalOrgID — not an account root, unless you intend to delegate the decision.
  • Deny statements for aws:SecureTransport and for uploads without your required encryption header are present.
  • No condition relies on a CIDR wider than /8, which Block Public Access treats as public.
  • All four Block Public Access settings are on unless you have a written reason for the one you turned off.
  • You ran the policy past IAM Access Analyzer for S3 before saving it, and read the findings rather than archiving them.
Try it in BucketDesk

Starter is free. Deploy a scoped role with CloudFormation, sign in, and browse, without handing anyone an access key.

Connect a bucket

Primary sources

Discussion

0 comments · open to guests · moderated
Comments appear after a quick review.

Liked this? Get the next article by email. No schedule, no filler, one click to leave.

Keep reading

All writing →