Almost every public S3 “leak” is a misconfiguration, not a vulnerability — a permissive policy, a forgotten ACL, an over-broad principal. This guide hardens S3 the way it’s done across a real org: a clear access-decision model, account-wide guardrails, a bucket-policy data perimeter, a deliberate KMS strategy, immutability against ransomware, and replication for DR.
The S3 access-decision model
Before touching policy, get the evaluation order straight, because most mistakes come from misreading it. For a given request, S3 layers four mechanisms:
- IAM identity policies — what a principal in your account is allowed to do.
- Bucket policies (resource policies) — what the bucket allows, including cross-account and anonymous access.
- Block Public Access (BPA) — a hard override that strips public grants regardless of policy or ACL.
- ACLs — the legacy per-object grant model, now disabled by default on new buckets.
The mental model: an explicit Deny anywhere always wins. Absent a deny, access needs an Allow from the relevant policy set. For same-account calls, an Allow in either the IAM policy or the bucket policy is sufficient. For cross-account calls, you need an Allow on both sides — the caller’s IAM policy and the bucket policy. BPA sits above all of it: if a grant would make an object or bucket public, BPA blocks the request outright.
Mental model: BPA is your seatbelt, the bucket policy is your steering. BPA stops the catastrophic public-exposure outcome; the bucket policy is where you do the precise work of confining access to your org. Never rely on one alone.
Disabled ACLs are the modern default
Since April 2023, new buckets are created with Bucket owner enforced object ownership, which disables ACLs entirely. The bucket owner automatically owns every object, and access is governed purely by policies. This is the configuration you want everywhere: ACLs are a per-object side channel that’s almost impossible to audit at scale. The rest of this guide assumes ACLs are off.
Step 1 — Enforce account-wide Block Public Access and lock ownership
Set BPA at the account level first. This is the single highest-leverage control: it applies to every existing and future bucket in the account and supersedes any per-bucket setting.
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
aws s3control put-public-access-block \
--account-id "$ACCOUNT_ID" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
The four flags do distinct jobs. BlockPublicAcls and IgnorePublicAcls neutralize public ACLs (both new grants and existing ones). BlockPublicPolicy rejects any new bucket policy that grants public access; RestrictPublicBuckets then ignores public and cross-account access from any policy that is already public-scoped. Turn on all four.
In a multi-account org, do not click this 500 times. Attach a Service Control Policy that denies turning BPA off, so even an account admin can’t regress it:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyDisablingAccountBPA",
"Effect": "Deny",
"Action": "s3:PutAccountPublicAccessBlock",
"Resource": "*"
}
]
}
For ownership, confirm new buckets use BucketOwnerEnforced. On any older bucket still allowing ACLs, migrate after auditing existing object ownership:
aws s3api put-bucket-ownership-controls \
--bucket my-bucket \
--ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'
Pitfall: flipping to
BucketOwnerEnforcedbreaks any workflow that depends on ACLs (some legacy log-delivery and cross-account upload patterns historically usedbucket-owner-full-control). Check CloudTrail forPutObjectAclcalls before you switch.
Step 2 — Build a bucket-policy data perimeter
A data perimeter is a set of guardrails ensuring only trusted identities access your resources from trusted networks, and only trusted resources are reachable. For S3 you express it as deny statements in the bucket policy. Three controls do most of the work.
Confine to your organization. Deny any principal whose AWS Organizations ID isn’t yours. This single statement stops the classic “wrong account ID in the policy” and confused-deputy exposures:
{
"Sid": "DenyOutsideMyOrg",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
],
"Condition": {
"StringNotEquals": { "aws:PrincipalOrgID": "o-exampleorgid" },
"Bool": { "aws:PrincipalIsAWSService": "false" }
}
}
The aws:PrincipalIsAWSService guard is important: without it you can accidentally block legitimate AWS service principals (logging, replication) that act on your behalf and don’t carry an org ID.
Confine to your networks. Deny requests that don’t arrive over your VPC endpoints or known egress IPs. This prevents credentials that leak outside your environment from being usable against your buckets:
{
"Sid": "DenyOffNetwork",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
"Condition": {
"StringNotEqualsIfExists": { "aws:SourceVpce": ["vpce-0abc123", "vpce-0def456"] },
"NotIpAddressIfExists": { "aws:SourceIp": ["203.0.113.0/24"] },
"BoolIfExists": { "aws:PrincipalIsAWSService": "false" },
"Bool": { "aws:ViaAWSService": "false" }
}
}
Use the IfExists variants and the aws:ViaAWSService guard so you don’t break service-to-service calls (e.g., CloudFront via OAC, Athena) that legitimately originate off your VPCs.
Require TLS in transit. A short, universal statement every bucket should carry:
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
"Condition": { "Bool": { "aws:SecureTransport": "false" } }
}
Note: scope the
Resourceto both the bucket ARN and the/*object ARN. Bucket-level actions (ListBucket) act on the bucket ARN; object actions act on the object ARN. Omitting one silently leaves a gap.
Step 3 — Encryption strategy: SSE-S3 vs. SSE-KMS
Since January 2023, S3 applies default encryption to every new object — there is no such thing as an unencrypted object at rest anymore. The real decision is which key management model, and the trade-off is governance versus cost.
| Aspect | SSE-S3 (AES256) |
SSE-KMS (aws:kms) |
|---|---|---|
| Key management | AWS-managed, transparent | Your CMK; you control the key policy |
| Audit trail | No per-object KMS event | Each decrypt is a CloudTrail KMS event |
| Access control | Bucket/IAM policy only | Bucket/IAM and KMS key policy (defense in depth) |
| Cost | No KMS request charges | KMS request charges per object op |
| Cross-account/region | Simple | Must grant key usage; CMK must exist in the replica region |
For anything sensitive, use SSE-KMS with a customer-managed key. The KMS key policy becomes a second, independent authorization layer: even a principal allowed by the bucket policy still needs kms:Decrypt on the CMK. That separation is what lets a security team gate decryption centrally.
Control the KMS request bill with S3 Bucket Keys. Naively, every GetObject/PutObject on an SSE-KMS bucket is a KMS API call, which gets expensive on hot buckets. An S3 Bucket Key generates a short-lived bucket-level data key so S3 stops calling KMS per object — typically cutting KMS request costs by up to ~99% on high-throughput buckets. Always enable it with SSE-KMS:
aws s3api put-bucket-encryption \
--bucket my-bucket \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234"
},
"BucketKeyEnabled": true
}]
}'
Then enforce it so nobody uploads with a weaker mode. Add a bucket-policy statement that denies writes lacking the correct encryption header:
{
"Sid": "DenyWrongEncryption",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringNotEquals": {
"s3:x-amz-server-side-encryption": "aws:kms",
"s3:x-amz-server-side-encryption-aws-kms-key-id":
"arn:aws:kms:us-east-1:111122223333:key/abcd-1234"
}
}
}
Pitfall: the KMS key policy, not just IAM, is the source of truth for a CMK. If replication or a consumer account “can’t decrypt,” the missing grant is almost always in the key policy. And remember every CMK accrues a monthly charge plus request costs — consolidate to a sensible number of keys per data domain rather than one per bucket.
Step 4 — Immutability and ransomware resilience with Object Lock
Ransomware against S3 looks like mass overwrite or delete using stolen credentials. The defense is S3 Object Lock, a write-once-read-many (WORM) control. Object Lock requires versioning and must be enabled at bucket creation — you cannot retrofit it onto an existing bucket via the API.
aws s3api create-bucket \
--bucket my-immutable-bucket \
--region us-east-1 \
--object-lock-enabled-for-bucket
# Versioning is implied/required; confirm it
aws s3api put-bucket-versioning \
--bucket my-immutable-bucket \
--versioning-configuration Status=Enabled
Object Lock has two retention modes, and the difference is the whole point:
- Governance mode — protected, but principals with the
s3:BypassGovernanceRetentionpermission can override. Use for general accident-prevention. - Compliance mode — no one, not even the account root, can delete or shorten retention until it expires. Use for true regulatory WORM (SEC 17a-4-style records).
Set a default retention rule so every new object inherits protection, and optionally add a legal hold for indefinite retention independent of any date:
aws s3api put-object-lock-configuration \
--bucket my-immutable-bucket \
--object-lock-configuration '{
"ObjectLockEnabled": "Enabled",
"Rule": { "DefaultRetention": { "Mode": "COMPLIANCE", "Days": 30 } }
}'
Pitfall: Compliance mode is genuinely irreversible. If you set a 7-year retention by mistake, you (and AWS Support) cannot delete those objects early, and you keep paying for that storage. Pilot with Governance mode first, and reserve Compliance for data you are legally required to retain.
Step 5 — Cross-region and same-region replication
Replication serves two needs: DR (cross-region, CRR) and compliance/data-residency or log aggregation (same-region, SRR). Both need versioning on source and destination. Configure it with an explicit IAM role S3 assumes to copy objects:
aws s3api put-bucket-replication \
--bucket source-bucket \
--replication-configuration '{
"Role": "arn:aws:iam::111122223333:role/s3-replication-role",
"Rules": [{
"ID": "ReplicateAllToDR",
"Status": "Enabled",
"Priority": 1,
"Filter": {},
"DeleteMarkerReplication": { "Status": "Disabled" },
"Destination": {
"Bucket": "arn:aws:s3:::dr-bucket",
"StorageClass": "STANDARD_IA",
"EncryptionConfiguration": {
"ReplicaKmsKeyID": "arn:aws:kms:us-west-2:111122223333:key/dr-key"
}
},
"SourceSelectionCriteria": {
"SseKmsEncryptedObjects": { "Status": "Enabled" }
}
}]
}'
Two details matter for a real deployment. First, KMS-encrypted objects need SseKmsEncryptedObjects enabled and the replication role must hold kms:Decrypt on the source key and kms:Encrypt/GenerateDataKey on the destination key — the destination CMK must exist in the destination region. Second, decide deliberately on delete-marker replication: leaving it disabled (as above) means a delete in the source does not propagate, which is exactly what you want for ransomware resilience — the DR copy survives a malicious delete. If you need a hard RPO, layer on S3 Replication Time Control (RTC), which provides a 15-minute SLA and replication metrics.
Step 6 — Continuous monitoring
Guardrails drift. Three services keep you honest:
IAM Access Analyzer for S3 continuously evaluates bucket policies and flags any bucket shared outside your account or org, and any that’s public. Stand up an org-level analyzer once:
aws accessanalyzer create-analyzer \
--analyzer-name org-s3-analyzer \
--type ORGANIZATION
Findings land in Security Hub; wire them to an alert. A finding that a bucket is “shared with an external account” is your tripwire for an unintended cross-account grant.
Amazon Macie discovers what’s actually in your buckets — it uses managed and custom data identifiers to find PII, secrets, and credentials, and flags buckets that are public or unencrypted. Run it on a schedule against your sensitive-data accounts; it’s how you catch the developer who dropped a database dump into a logs bucket.
Server access logging or CloudTrail data events give you the request-level audit trail. Prefer CloudTrail data events for object-level operations (GetObject, DeleteObject) you can query in Athena and alert on; use S3 server access logs when you need every request including ones CloudTrail doesn’t capture. Send the logs to a separate, locked logging account so an attacker who owns the data account can’t erase their tracks.
Enterprise scenario
A fintech platform team rolled out the DenyWrongEncryption write-time statement across ~400 buckets via a Terraform module, pinning every PutObject to a single per-domain CMK. Within an hour, their CloudTrail-to-S3 pipeline and an ALB access-log bucket started failing silently — no objects, no errors surfaced to the app team. The gotcha: AWS service principals that deliver logs (CloudTrail, ELB, Config) write with their own encryption context, and several don’t send the s3:x-amz-server-side-encryption-aws-kms-key-id header the deny clause demanded, so the service-side PutObject got AccessDenied and the delivery was just dropped.
The fix was twofold. First, scope the deny to human/role principals by excluding service calls, so AWS-managed log delivery isn’t caught by the strict key-ID check:
{
"Sid": "DenyWrongEncryption",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringNotEqualsIfExists": {
"s3:x-amz-server-side-encryption-aws-kms-key-id":
"arn:aws:kms:us-east-1:111122223333:key/abcd-1234"
},
"Null": { "s3:x-amz-server-side-encryption": "false" },
"Bool": { "aws:PrincipalIsAWSService": "false" }
}
}
Second, they added the log-delivery service principals to the CMK key policy so the services could actually generate data keys against it. The lesson: encryption-enforcement guardrails must be tested against your service writers, not just developers — and the failure mode is a dropped object, not a loud error.
Verify
Prove the controls work rather than assuming. Run these against a target bucket and confirm each expected result.
# 1. Account-level BPA is fully on (all four flags true)
aws s3control get-public-access-block --account-id "$ACCOUNT_ID"
# 2. Bucket reports BucketOwnerEnforced (ACLs disabled)
aws s3api get-bucket-ownership-controls --bucket my-bucket
# 3. Default encryption is SSE-KMS with Bucket Key on
aws s3api get-bucket-encryption --bucket my-bucket
# 4. A plaintext (no-TLS) request is refused — expect AccessDenied
aws s3api get-object --bucket my-bucket --key test.txt out.txt \
--endpoint-url http://s3.us-east-1.amazonaws.com ; echo "exit=$?"
# 5. An upload with the wrong SSE header is denied — expect AccessDenied
aws s3api put-object --bucket my-bucket --key bad.txt \
--server-side-encryption AES256 ; echo "exit=$?"
# 6. Access Analyzer shows no unexpected external/public findings
aws accessanalyzer list-findings \
--analyzer-arn "$ANALYZER_ARN" \
--filter '{"status":{"eq":["ACTIVE"]}}'
A passing posture: steps 1-3 return the locked configuration, steps 4-5 fail closed with AccessDenied, and step 6 lists only findings you’ve explicitly accepted.
Hardening checklist
Lifecycle, tiering, and the exposed-bucket runbook
Two loose ends close out a production setup. Lifecycle policies control cost and risk together: transition cold objects to STANDARD_IA, GLACIER_IR, or DEEP_ARCHIVE, expire noncurrent versions after a retention window (versioning without expiry quietly grows your bill forever), and abort incomplete multipart uploads after a few days so orphaned parts don’t accumulate. If you’d rather not hand-tune access patterns, S3 Intelligent-Tiering moves objects between tiers automatically.
Finally, keep a one-page exposed-bucket runbook so an alert doesn’t turn into a scramble:
- Contain — apply account-level BPA immediately if it somehow isn’t on; this neutralizes public access without waiting to untangle the policy.
- Scope — pull CloudTrail data events and server access logs to determine what was accessed and by whom, over what window.
- Classify — run Macie on the bucket to confirm whether exposed objects contained PII or secrets, which drives breach-notification obligations.
- Rotate — invalidate any credentials, keys, or tokens that were stored in or accessible via the bucket.
- Remediate and prevent — fix the offending policy/ACL, then add the missing guardrail (SCP, perimeter statement, or Access Analyzer alert) so the same gap can’t reopen.
The throughline of all six steps is layering: BPA above policy, the data perimeter inside policy, KMS as a second authorization plane, Object Lock and replication beneath the data itself, and Access Analyzer plus Macie watching the whole stack. No single control is sufficient, but together they make an accidental S3 exposure something your guardrails reject — not something your customers discover for you.