Safeguard
Cloud Security

How to prevent public access to AWS S3 buckets

A practical walkthrough for locking down AWS S3 buckets: Block Public Access, bucket policies, encryption, and how Safeguard catches misconfigurations early.

Karan Patel
Cloud Security Engineer
7 min read

Misconfigured Amazon S3 buckets remain one of the most common — and most avoidable — causes of cloud data breaches. A single overly permissive bucket policy or a disabled account-level setting can expose customer records, source code, backups, or credentials to the entire internet, often for months before anyone notices. If you want to prevent S3 bucket public access across your AWS environment, you need more than a one-time fix: you need layered controls at the account, bucket, and object level that stay enforced as your infrastructure changes.

This guide walks through the concrete steps to lock down S3 — from enabling account-wide Block Public Access to writing least-privilege bucket policies, enforcing encryption, and verifying that nothing slipped through. By the end, you'll have a repeatable process for auditing every bucket in your account and closing the gaps that attackers scan for constantly.

Step 1: Audit Your Current S3 Exposure

Before changing anything, find out what's actually public today. AWS Config, Trusted Advisor, and the S3 console's Access column can all flag public buckets, but the fastest way to get a full picture from the command line is the list-buckets and get-bucket-policy-status APIs:

for bucket in $(aws s3api list-buckets --query "Buckets[].Name" --output text); do
  status=$(aws s3api get-bucket-policy-status --bucket "$bucket" \
    --query "PolicyStatus.IsPublic" --output text 2>/dev/null)
  echo "$bucket: IsPublic=$status"
done

Also check get-public-access-block per bucket and at the account level, since a bucket can inherit a permissive posture if account settings were never configured. Treat every bucket flagged IsPublic=True as an incident until you've confirmed it's intentional (for example, a static website bucket that's meant to be public).

Step 2: Prevent S3 Bucket Public Access at the Account Level

The single highest-leverage control is the S3 Block Public Access setting, and it should be turned on for every AWS account by default. It overrides ACLs and bucket policies that would otherwise grant public access, so even a misconfigured policy pushed by a developer can't expose data.

Enable it account-wide with:

aws s3control put-public-access-block \
  --account-id 123456789012 \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

If you manage infrastructure as code, enforce the same thing with an SCP (Service Control Policy) at the AWS Organizations level so individual accounts can't opt out:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Action": [
        "s3:PutAccountPublicAccessBlock",
        "s3:PutBucketPublicAccessBlock"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalArn": "arn:aws:iam::123456789012:role/SecurityAdmin"
        }
      }
    }
  ]
}

This ensures the s3 block public access setting can't be silently disabled by a script, a Terraform apply, or a well-meaning engineer trying to unblock a demo.

Step 3: Apply Block Public Access at the Bucket Level

Account-level blocking covers most cases, but apply the same configuration per bucket too, especially in multi-account setups where the account-level control may not be centrally managed:

aws s3api put-public-access-block \
  --bucket my-sensitive-data-bucket \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

In Terraform, this looks like:

resource "aws_s3_bucket_public_access_block" "this" {
  bucket = aws_s3_bucket.this.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Make this a required module for every bucket resource in your IaC templates, and add a policy-as-code check (Checkov, tfsec, or OPA/Conftest) that fails CI if a bucket is defined without it.

Step 4: Write Least-Privilege Bucket Policies

Block Public Access stops accidental exposure, but you still need sound s3 bucket policy security for legitimate cross-account or service access. Avoid wildcard principals and always scope policies to specific accounts, roles, or VPC endpoints.

A safe pattern for restricting access to a specific VPC endpoint, while also denying any request that isn't sent over TLS:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowAccessFromSpecificVPCE",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-sensitive-data-bucket/*",
      "Condition": {
        "StringEquals": {
          "aws:SourceVpce": "vpce-0123456789abcdef0"
        }
      }
    },
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::my-sensitive-data-bucket",
        "arn:aws:s3:::my-sensitive-data-bucket/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    }
  ]
}

Never use "Principal": "*" without a tightly scoped condition, and periodically diff bucket policies against a known-good baseline so drift gets caught quickly.

Step 5: Disable ACLs and Enforce Bucket Owner Enforced

Legacy S3 ACLs are a frequent source of accidental grants (AllUsers or AuthenticatedUsers read/write). Set the object ownership control to BucketOwnerEnforced, which disables ACLs entirely and makes the bucket owner the sole owner of every object:

aws s3api put-bucket-ownership-controls \
  --bucket my-sensitive-data-bucket \
  --ownership-controls Rules=[{ObjectOwnership=BucketOwnerEnforced}]

This closes off an entire class of misconfiguration — including cases where an uploading account grants public-read on individual PutObject calls, which bucket policies alone won't stop.

Step 6: Encrypt Data and Restrict Key Access

Encryption doesn't prevent public access by itself, but it limits the blast radius if a bucket is briefly exposed. Enforce default encryption with a customer-managed KMS key, and require encryption on every upload:

aws s3api put-bucket-encryption \
  --bucket my-sensitive-data-bucket \
  --server-side-encryption-configuration '{
    "Rules": [{
      "ApplyServerSideEncryptionByDefault": {
        "SSEAlgorithm": "aws:kms",
        "KMSMasterKeyID": "arn:aws:kms:us-east-1:123456789012:key/abc-123"
      },
      "BucketKeyEnabled": true
    }]
  }'

Scope the KMS key policy so only specific roles can decrypt, adding a second layer of defense beyond bucket-level access control.

Step 7: Enable Logging, Alerts, and Continuous Monitoring

Turn on S3 server access logging or CloudTrail data events for the bucket, and wire up EventBridge or AWS Config managed rules (s3-bucket-public-read-prohibited, s3-bucket-public-write-prohibited) to alert immediately if a bucket's ACL or policy changes to allow public access. Following aws s3 security best practices means treating any drift toward public exposure as a page-worthy event, not something caught in a quarterly audit.

Verifying and Troubleshooting Your S3 Access Controls

Once controls are in place, confirm they're actually working:

  • Re-run the audit script from Step 1. Every bucket should report IsPublic=false unless it's an intentional public asset (for example, static site content served behind CloudFront with Origin Access Control).
  • Check for conflicting Block Public Access states. If a bucket-level setting shows false but the account-level setting is true, the more restrictive setting wins — but reconcile them anyway to avoid confusion during incident response.
  • Test from an anonymous session. Use curl or an incognito browser against the object URL; you should get AccessDenied, not the object contents.
  • Watch for ACL drift on uploads. Applications that call PutObject with x-amz-acl: public-read will start failing once BucketOwnerEnforced is set — this is expected, and the application code should be updated to stop setting ACLs rather than the setting rolled back.
  • Validate cross-account access still works. After tightening a bucket policy, test the specific roles or services that legitimately need access (data pipelines, CDN origins, backup jobs) so you don't break production while closing a gap.
  • Check CloudTrail for PutBucketPolicy and PutBucketAcl events. If you see unexpected changes, someone — or some automation — is actively working around your controls, and that's worth investigating on its own.

How Safeguard Helps

Manually auditing every bucket, policy, and ACL across dozens of AWS accounts doesn't scale, and static settings can drift the moment a new bucket is created outside your IaC pipeline. Safeguard continuously monitors your AWS environment for exactly this kind of drift — flagging buckets where Block Public Access is disabled, policies grant overly broad principals, or ACLs reintroduce public read/write access, before that exposure becomes a breach.

Safeguard also maps S3 exposure back to your software supply chain: a public bucket holding build artifacts, container images, or CI/CD secrets is a very different risk than a public marketing-asset bucket, and it should be triaged differently. By correlating cloud misconfigurations with the code, pipelines, and dependencies they touch, Safeguard helps your team prioritize the S3 findings that actually put your supply chain at risk, and verify remediation automatically instead of waiting for the next manual audit.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.