Safe object storage defaults: block public access, versioning, lifecycle
For developers locking down a new or existing object storage bucket without breaking app uploads. You will end with a private bucket, versioning enabled, and lifecycle rules that clean up old versions and incomplete multipart uploads using exact AWS CLI commands and verification steps.
TL;DR — For a safe default bucket, do three things immediately: block all public access, enable versioning, and add lifecycle rules for noncurrent versions plus incomplete multipart uploads. The most common miss is enabling versioning but forgetting lifecycle cleanup, which quietly grows storage bills and leaves deleted objects recoverable forever. Reading time: ~5 min
Goal
When you finish, your object storage bucket is private at the account and bucket levels, rejects public ACLs and public bucket policies, keeps object history with versioning, and automatically expires old noncurrent versions and abandoned multipart uploads.
Prerequisites
- An object storage account and a bucket already created; this guide uses Amazon S3 and AWS CLI because the flags and error messages are concrete
- IAM permissions to update bucket public access settings, versioning, lifecycle, and bucket policy; in AWS this typically includes
s3:PutBucketPublicAccessBlock,s3:PutBucketVersioning,s3:PutLifecycleConfiguration,s3:GetBucket* - AWS CLI v2 — check with:
aws --version
- A configured CLI profile and region — check with:
aws sts get-caller-identity
aws configure list
- Your bucket name in an environment variable:
export BUCKET=my-team-prod-assets
export AWS_REGION=us-east-1
Steps
Step 1: Inspect the current bucket state
Run these read-only checks first so you know whether you are fixing drift or starting clean.
aws s3api get-public-access-block --bucket "$BUCKET" --region "$AWS_REGION"
aws s3api get-bucket-versioning --bucket "$BUCKET" --region "$AWS_REGION"
aws s3api get-bucket-policy-status --bucket "$BUCKET" --region "$AWS_REGION"
aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET" --region "$AWS_REGION"
What you should see on success: JSON output for existing settings, or errors like NoSuchPublicAccessBlockConfiguration / NoSuchLifecycleConfiguration if not yet configured.
Step 2: Block all public access on the bucket
This is the safest default for application buckets. It prevents public ACLs and public bucket policies from taking effect.
aws s3api put-public-access-block \
--bucket "$BUCKET" \
--region "$AWS_REGION" \
--public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'
aws s3api get-public-access-block \
--bucket "$BUCKET" \
--region "$AWS_REGION"
What you should see on success: the second command returns all four values as true.
Real output shape:
{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}
Step 3: Enable versioning
Versioning protects against accidental overwrites and deletes. New objects become versioned immediately; existing objects do not get retroactive versions.
aws s3api put-bucket-versioning \
--bucket "$BUCKET" \
--region "$AWS_REGION" \
--versioning-configuration Status=Enabled
aws s3api get-bucket-versioning \
--bucket "$BUCKET" \
--region "$AWS_REGION"
What you should see on success: {"Status": "Enabled"}.
Step 4: Add lifecycle rules for old versions and incomplete multipart uploads
This keeps versioning from turning into unbounded cost. The rule below does three practical things: abort incomplete multipart uploads after 7 days, expire noncurrent versions after 30 days, and permanently remove noncurrent versions older than 90 days while keeping the latest 5 noncurrent versions.
⚠️ This step deletes data automatically once objects meet the lifecycle conditions. If legal hold, retention, or long rollback windows apply, change the day counts before you apply it.
Create the lifecycle JSON:
cat > lifecycle.json <<'JSON'
{
"Rules": [
{
"ID": "abort-incomplete-multipart-uploads",
"Status": "Enabled",
"Filter": {
"Prefix": ""
},
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 7
}
},
{
"ID": "expire-old-noncurrent-versions",
"Status": "Enabled",
"Filter": {
"Prefix": ""
},
"NoncurrentVersionExpiration": {
"NoncurrentDays": 90,
"NewerNoncurrentVersions": 5
}
}
]
}
JSON
aws s3api put-bucket-lifecycle-configuration \
--bucket "$BUCKET" \
--region "$AWS_REGION" \
--lifecycle-configuration file://lifecycle.json
aws s3api get-bucket-lifecycle-configuration \
--bucket "$BUCKET" \
--region "$AWS_REGION"
What you should see on success: the final command returns your two rules as JSON.
Step 5: Remove any public bucket policy if one exists
Public access block prevents public policies from being effective, but removing stale public policy avoids future confusion and security findings.
First inspect the policy:
aws s3api get-bucket-policy \
--bucket "$BUCKET" \
--region "$AWS_REGION" \
--query Policy \
--output text
If you see a statement with "Principal":"*" or "Principal": {"AWS":"*"}, delete the bucket policy:
aws s3api delete-bucket-policy \
--bucket "$BUCKET" \
--region "$AWS_REGION"
What you should see on success: no output and exit code 0.
Step 6: Prove public reads are blocked
Upload a test object, then try to read it without credentials.
echo 'private test' > /tmp/private-test.txt
aws s3 cp /tmp/private-test.txt "s3://$BUCKET/private-test.txt" --region "$AWS_REGION"
curl -I "https://$BUCKET.s3.$AWS_REGION.amazonaws.com/private-test.txt"
What you should see on success: curl returns 403 Forbidden, not 200 OK.
Real output shape:
HTTP/1.1 403 Forbidden
x-amz-request-id: 7F1AEXAMPLE
x-amz-id-2: long-base64-value
Content-Type: application/xml
Transfer-Encoding: chunked
Date: Tue, 30 Oct 2026 10:15:22 GMT
Server: AmazonS3
Verify it works
Run this full check set:
aws s3api get-public-access-block --bucket "$BUCKET" --region "$AWS_REGION"
aws s3api get-bucket-versioning --bucket "$BUCKET" --region "$AWS_REGION"
aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET" --region "$AWS_REGION"
aws s3api put-object --bucket "$BUCKET" --key verify.txt --body /tmp/private-test.txt --region "$AWS_REGION"
aws s3api delete-object --bucket "$BUCKET" --key verify.txt --region "$AWS_REGION"
aws s3api list-object-versions --bucket "$BUCKET" --prefix verify.txt --region "$AWS_REGION"
curl -I "https://$BUCKET.s3.$AWS_REGION.amazonaws.com/verify.txt"
Expected results:
get-public-access-blockshows all four booleanstrueget-bucket-versioningshows"Status": "Enabled"get-bucket-lifecycle-configurationreturns your rules without errorlist-object-versionsshows at least oneDeleteMarkersentry and one priorVersionsentry forverify.txtcurl -IreturnsHTTP/1.1 403 Forbidden
Common pitfalls
Public access block set only at bucket level, not account level
Mistake: you lock down one bucket but leave the account able to create public buckets later.
Symptom: this bucket is private, but security scans still flag the account or newly created buckets drift public.
Fix: in your provider dashboard, enable account-level block public access too; in AWS S3 console this is S3 → Block Public Access settings for this account and set all four options to On.
Existing apps still use object ACLs like public-read
Mistake: upload code still sends ACLs after you turn on BlockPublicAcls.
Symptom: uploads fail with errors shaped like:
An error occurred (AccessControlListNotSupported) when calling the PutObject operation: The bucket does not allow ACLs
or
An error occurred (AccessDenied) when calling the PutObject operation: Access Denied
Fix: remove --acl public-read from CLI/scripts and stop setting canned ACLs in SDK calls.
Versioning was enabled after important objects were already uploaded
Mistake: assuming old objects now have historical versions.
Symptom: list-object-versions shows only one version for older keys, or none before the enable date.
Fix: accept that versioning is not retroactive; if you need a baseline copy, re-upload critical objects once after enabling versioning.
Lifecycle rule deletes more or less than you intended
Mistake: using a broad prefix or no retention count without thinking through rollback needs.
Symptom: old versions disappear sooner than expected, or storage keeps growing because too many noncurrent versions remain.
Fix: inspect the applied JSON with get-bucket-lifecycle-configuration and set explicit values for NoncurrentDays and NewerNoncurrentVersions.
Testing with an authenticated browser session gives false confidence
Mistake: checking access in a browser or tool that already has cloud credentials.
Symptom: the object opens for you, so you assume it is public, but anonymous users still get blocked; or the reverse if your tool strips auth.
Fix: test anonymous access with plain curl -I https://... from a shell that is not signing requests.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI