Amazon S3

S3 bucket policy security best practices

Keep Block Public Access on, deny non-TLS requests, scope Principals to specific roles, use conditions for service principals, and remember a bucket has exactly one policy.

Published · Updated .

A bucket policy is the last line of defence for the data in a bucket: it applies to every request, from every principal, whatever their own permissions say. This guide collects the practices that keep bucket policies predictable and private by default, with policies you can adapt.

Know the mechanics: one policy, replaced as a whole

  • A bucket has at most one bucket policy. put-bucket-policy replaces the whole document, so adding a statement means reading the current policy, merging, and writing it back. Keep the policy in source control or IaC so the merge is reviewed.
  • Bucket policies can be up to 20 KB.
  • Every statement needs a Principal, and every Resource must be the bucket ARN (arn:aws:s3:::amzn-s3-demo-bucket) or object ARNs within it (arn:aws:s3:::amzn-s3-demo-bucket/* or a prefix).

Keep S3 Block Public Access on

Since April 2023, new buckets are created with all four Block Public Access settings enabled and ACLs disabled (Object Ownership set to bucket owner enforced). With BlockPublicPolicy enabled, S3 rejects a bucket policy that would grant public access. Leave these settings on at the account level. In an organization, the primary control since November 2025 is an AWS Organizations S3 policy that enforces Block Public Access for every account under a root or OU, including accounts added later (see S3 policies in AWS Organizations). An SCP that denies changes to the settings remains useful as defense in depth (see the SCP security baseline example).

Deny requests that do not use TLS

S3 endpoints accept plain HTTP. A deny on aws:SecureTransport = false makes encryption in transit mandatory for every caller:

Bucket policy: baseline denies · Amazon S3 bucket policyOpen in S3 Bucket Policy Generator
{
  "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"
        }
      }
    }
  ]
}

Principal: "*" in a Deny statement is correct: it applies the rule to everyone. The risk with "*" is only in Allow statements.

Grant to specific roles, and condition service principals

Name the role that needs access (arn:aws:iam::111122223333:role/ReportReader) rather than an account root. Granting to an account root delegates the decision to that account's IAM administrators.

When the principal is an AWS service (CloudFront, S3 server access logging, CloudTrail), add a condition that ties the grant to your resource, such as aws:SourceArn or aws:SourceAccount. Without it, the same service acting for another customer could match the statement. This is the cross-service confused deputy problem.

Bucket policy: CloudFront OAC read access · Amazon S3 bucket policyOpen in S3 Bucket Policy Generator
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontOacRead",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::amzn-s3-demo-bucket/*",
      "Condition": {
        "StringEquals": {
          "aws:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EDFDVBD6EXAMPLE"
        }
      }
    }
  ]
}

This also shows the preferred way to host a static website: a private bucket behind CloudFront with Origin Access Control, instead of a public bucket.

Add a perimeter for sensitive buckets

For buckets that must never leave your organisation, a deny on aws:PrincipalOrgID stops future mistakes, such as an over-broad Allow added during an incident or an object made public, from exposing data:

Bucket policy: baseline denies · Amazon S3 bucket policyOpen in S3 Bucket Policy Generator
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyPrincipalsOutsideOrganization",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::amzn-s3-demo-bucket",
        "arn:aws:s3:::amzn-s3-demo-bucket/*"
      ],
      "Condition": {
        "StringNotEqualsIfExists": {
          "aws:PrincipalOrgID": "o-exampleorgid"
        },
        "BoolIfExists": {
          "aws:PrincipalIsAWSService": "false"
        }
      }
    }
  ]
}

The aws:PrincipalIsAWSService condition keeps AWS service principals (which have no organisation ID) working. Test with every legitimate consumer first: partners outside the organisation are blocked too. The HTTPS and perimeter example combines both deny statements in one bucket policy, with checks that show each one working.

Encryption: prefer default encryption over header-matching denies

Since January 2023, S3 encrypts every new object (SSE-S3) by default. If you need SSE-KMS, configure it as the bucket's default encryption (ideally with an S3 Bucket Key to reduce KMS requests). Older guidance adds a deny for PutObject requests without an s3:x-amz-server-side-encryption header. That breaks clients that rely on the bucket default and send no header, so use it only when you must force every client to choose the key explicitly.

Change management and lockout

  • Always read the current policy before writing a new one, and keep a copy of it.
  • Broad Deny statements with Principal: "*" can lock out your own administrators. The AWS account root user of the bucket owner can still delete the bucket policy to recover, which is one reason to keep the root user secured with MFA and unused day to day.
  • Validate before applying (below), and roll out to a non-production bucket first.
Safe update workflow
aws s3api get-bucket-policy --bucket amzn-s3-demo-bucket --query Policy --output text > current.json
# edit a copy: new.json
aws accessanalyzer validate-policy --policy-type RESOURCE_POLICY --validate-policy-resource-type AWS::S3::Bucket --policy-document file://new.json
aws accessanalyzer check-no-public-access --resource-type AWS::S3::Bucket --policy-document file://new.json
aws s3api put-bucket-policy --bucket amzn-s3-demo-bucket --policy file://new.json

Common mistakes

  • Writing "Resource": "*" or another bucket's ARN. A bucket policy only governs its own bucket, so list that bucket and its objects explicitly.
  • Putting object actions on the bucket ARN, or s3:ListBucket on bucket/*. The action never matches that resource: if no action in the statement fits its resources, S3 rejects the policy (“Action does not apply to any resource(s) in statement”); if other actions do fit, the policy is accepted and the mismatched action simply grants nothing.
  • An Allow with Principal: "*" and no condition: public access, if Block Public Access is off.
  • Replacing the policy and dropping someone else's statements: always merge.

Verification

  1. In the S3 console, the bucket's Permissions tab shows whether the bucket is public, and why.
  2. An IAM Access Analyzer external access analyzer lists buckets shared outside your zone of trust.
  3. Test with a principal that should be allowed and one that should be denied, over HTTPS and HTTP.

Official AWS documentation