Docs/ Use Cases/ S3 public-access check

Is my S3 bucket public? Check it on your laptop

"Could anyone on the internet read this object?" is a question worth answering before the object exists in a real account, not after. With IAM_ENFORCEMENT=1, LocalEmu answers it the same way AWS does: an unsigned request is the anonymous caller, and it gets in only if a bucket policy explicitly grants public access.

Below, the same object is read three ways: unsigned while private, through a signed presigned link, and unsigned after a deliberate public grant. No AWS account, no cost, and nothing is ever actually exposed to the internet.

Prerequisite

Start LocalEmu with enforcement on so policies are actually applied: IAM_ENFORCEMENT=1 localemu start. Without it, every request is permitted and there is nothing to check.

1. A bucket with a private and a public object

Public bucket policies are blocked by default, just like AWS, so the setup turns that block off before anything can be made public.

Terminal: create the bucket and objects
# awsemu talks to LocalEmu on localhost:4566. No AWS account, no cost.
BUCKET=my-app-assets
awsemu s3api create-bucket --bucket $BUCKET

# Public bucket policies are blocked by default, exactly like AWS. Allow them.
awsemu s3api put-public-access-block --bucket $BUCKET --public-access-block-configuration BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false

# One object we want kept private, one we will deliberately expose.
echo 'top secret'  > secret.txt
echo 'hello world' > hello.txt
awsemu s3api put-object --bucket $BUCKET --key private/secret.txt --body secret.txt
awsemu s3api put-object --bucket $BUCKET --key public/hello.txt  --body hello.txt

2. Private means private

An unsigned read of the private object is denied. There is no bucket policy granting public access, so the anonymous caller has nothing to rely on.

Terminal: unsigned read of a private object
# An unsigned request: a browser, curl, anyone on the internet. No credentials.
curl -s -o /dev/null -w '%{http_code}' http://localhost:4566/$BUCKET/private/secret.txt
# -> 403   denied: the bucket has no policy granting public access

3. A signed link still works

A presigned URL is not anonymous: it carries a real credential in the query string. LocalEmu evaluates it as that principal, so the same private object is readable through the link.

Terminal: presigned read of the same object
# A presigned link is SIGNED: it carries a real, time-limited credential.
URL=$(awsemu s3 presign s3://$BUCKET/private/secret.txt --expires-in 300)
curl -s -o /dev/null -w '%{http_code}' "$URL"
# -> 200   allowed: evaluated as the real principal, not as anonymous

4. Make one prefix public, on purpose

Save this policy as public-read.json. It grants anonymous read on public/* only, deliberately leaving private/ alone.

public-read.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadForPublicPrefix",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-app-assets/public/*"
    }
  ]
}

Apply it, then read both objects unsigned. The public one is reachable; the private one is still denied because the grant does not cover it.

Terminal: apply the policy and re-check
awsemu s3api put-bucket-policy --bucket $BUCKET --policy file://public-read.json

# The unsigned request to the public prefix now succeeds...
curl -s -o /dev/null -w '%{http_code}' http://localhost:4566/$BUCKET/public/hello.txt
# -> 200   allowed by the Principal "*" grant on public/*

# ...but the private object is still denied. The grant only covers public/*.
curl -s -o /dev/null -w '%{http_code}' http://localhost:4566/$BUCKET/private/secret.txt
# -> 403

How LocalEmu decides

A request with no credentials is the anonymous principal, evaluated exactly like AWS:

  • There are no identity policies to consider, so access is granted only when a resource policy names Principal: "*" for that action. No public grant means denied.
  • A signed request (a normal SDK call or a presigned URL) is evaluated as its real principal instead, so it is never treated as anonymous.
  • An explicit Deny on Principal: "*" overrides any allow, the same precedence AWS uses.

This is for catching a public-access mistake inside your iteration loop, at zero cost and with no account. It is not a substitute for AWS or for tools like Access Analyzer; treat a clean local check as fast feedback, not a final audit.

Where to go next