The S3 bucket policy that reads like a lockdown and denies nothing
30 July 2026
There is a tool for this on the siteBucket policies are short, they read like English, and they are one of the few places where a document that is obviously correct on inspection grants the opposite of what it says.
Four that pass review.
1. A condition on a key the request does not carry
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"StringNotEquals": { "aws:PrincipalOrgID": "o-abc123" }
}
}
Written to deny everyone outside the organisation. It does, for principals that carry an org id.
An anonymous request does not have aws:PrincipalOrgID at all. Under
StringNotEquals, a key that is absent does not match, so the condition
evaluates false, so the Deny does not apply. The policy written to block
outsiders specifically does not apply to the most outside request possible.
The general rule is worth committing to memory: a negated condition on an absent key evaluates to “no match”, which for a Deny means “do not deny”. Anonymous requests are absent almost every principal-related key.
The fix is ...IfExists plus an explicit test for the anonymous case, or
restructuring to an Allow-with-condition rather than a Deny-with-negation:
"Condition": {
"StringNotEqualsIfExists": { "aws:PrincipalOrgID": "o-abc123" },
"BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
}
Even that needs testing rather than trusting. The reliable structure is to deny everything and allow narrowly, because an Allow that fails to match simply does not grant.
2. NotPrincipal with Deny
{
"Effect": "Deny",
"NotPrincipal": { "AWS": "arn:aws:iam::111122223333:role/admin" },
"Action": "s3:*",
"Resource": "arn:aws:s3:::my-bucket/*"
}
Reads as “deny everyone except the admin role”. It does not behave that way, and
AWS’s own documentation recommends against NotPrincipal for exactly this
reason.
A role’s requests arrive under several principal forms: the role ARN, the
assumed-role session ARN (arn:aws:sts::111122223333:assumed-role/admin/session),
and sometimes the account root. NotPrincipal matches literally, so a
session ARN is not the role ARN, and the Deny applies to the admin you were
exempting. Meanwhile a principal you did not consider may match the exemption
through a form you did not list.
Use Deny with Principal: "*" and a condition on aws:PrincipalArn, which
compares a value rather than matching a principal structure:
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": "arn:aws:s3:::my-bucket/*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::111122223333:role/admin"
}
}
}
ArnNotLike with a wildcard also handles the session-ARN forms, which the
literal principal match cannot.
3. aws:SourceIp, and the traffic that has no source IP
"Condition": {
"NotIpAddress": { "aws:SourceIp": ["203.0.113.0/24"] }
}
Intended to restrict access to the office range. Two holes.
Requests arriving through a VPC endpoint do not present a public source IP,
so aws:SourceIp does not apply the way you expect and the policy behaves
differently for in-VPC traffic than for internet traffic. If you mean “only from
my VPC”, the keys are aws:SourceVpce or aws:SourceVpc. If you mean both, you
need both conditions and they combine as AND within one condition block, which
is usually not what you want.
Second, and more fundamental: an IP allow-list is a network control expressed in the authorization layer. It grants access to anyone who can originate traffic from that range, which on a shared office network or a VPN with split tunnelling is a larger set than intended, and it says nothing about who they are.
Use it as defence in depth, never as the primary control.
4. The resource ARN that does not cover the action
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:role/reader" },
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": "arn:aws:s3:::my-bucket/*"
}
This grants GetObject and grants nothing for ListBucket.
S3 has bucket-level and object-level actions, and they take different resource
ARNs. s3:ListBucket operates on the bucket (arn:aws:s3:::my-bucket), not on
objects (arn:aws:s3:::my-bucket/*). The trailing /* excludes the bucket
itself.
The symptom is a role that can read a file if it already knows the key and gets Access Denied listing the directory, which reads as a permissions bug in the application. It needs both ARNs:
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
]
ListBucket, GetBucketLocation and ListBucketMultipartUploads are the
bucket-level ones people hit most.
The wrong instinct: add another Deny
When a policy is not restricting what it should, the reflex is to add another Deny statement. It usually makes the document harder to reason about without closing the hole, because the new statement often has the same absent-key or literal-match problem as the first.
Evaluation order is fixed and worth knowing, because it explains why:
- An explicit Deny anywhere wins, always.
- Otherwise an explicit Allow grants.
- Otherwise, implicit deny.
So a Deny that does not match contributes nothing, and stacking more of them adds statements that also do not match. The reliable structure is a small number of narrow Allows plus a single well-tested Deny for the guardrails you cannot express as an absence.
And bucket policies are only one input. The effective permission is the intersection of the identity policy, the bucket policy, any permissions boundary, any SCP, and Block Public Access. A bucket policy that grants public read does nothing while account-level Block Public Access is on, which is why so many of them sit unnoticed for months and then become an incident the day somebody disables it for an unrelated reason.
What changed recently
Block Public Access is on by default for new buckets, and ACLs are disabled by default with Object Ownership set to bucket owner enforced. Most of the classic “public bucket” advice predates this. The risk has moved from accidentally-public to over-broadly-shared: a policy granting a whole organisation, or a wildcard principal with a weak condition.
aws:PrincipalOrgID and aws:ResourceOrgID make org-scoped policies
practical without listing account ids, and they are the right tool for the first
example above, used carefully.
Access Analyzer for S3 will tell you which policies grant access outside your account or organisation, evaluating the whole chain rather than reading one document. It is the correct answer to “is this bucket actually exposed” and is worth turning on before hand-auditing policies.
Auditing what you have
1. Check Block Public Access at the account level first. If it is on, most public-access findings are theoretical. If it is off, find out why before anything else.
2. Run Access Analyzer rather than reading policies one at a time. It evaluates the intersection; a human reading one document cannot.
3. For each Deny with a negated condition, ask what an anonymous request carries. That single question finds most of the first failure mode.
4. Grep for NotPrincipal. Treat every occurrence as a bug until proven
otherwise.
5. Check bucket-level actions have a bucket-level ARN. Mechanical, and it finds real broken grants as well as real over-grants.
6. Test with the policy simulator, using the assumed-role session ARN rather than the role ARN, because that is what actually arrives.
When a permissive policy is fine
A genuinely public bucket for static assets is a legitimate configuration.
The problem is never that a bucket is public; it is that a bucket is public and
nobody meant it. Document the intent in the policy Sid and it stops being a
finding.
A bucket only reachable from one account with no cross-account trust does not need elaborate conditions. Conditions have a cost: every one is another thing that can fail open in a way nobody notices, and a simple policy that is obviously correct beats a clever one that is probably correct.
When the identity policy already constrains it. Duplicating a restriction in both places is defensible defence in depth and is also two things to keep in sync. Decide which layer owns the decision.
The short version
- A negated condition on an absent key does not match, so a Deny does not deny. Anonymous requests are absent almost everything.
NotPrincipalmatches literally and misses assumed-role session ARNs. Useaws:PrincipalArnwithArnNotLike.aws:SourceIpdoes not cover VPC endpoint traffic. Useaws:SourceVpce.- Bucket-level actions like
ListBucketneed the bucket ARN, notbucket/*. - Explicit Deny always wins; a Deny that does not match contributes nothing.
- The effective permission is the intersection of five things. Read them all, or run Access Analyzer.
The S3 bucket policy analyzer evaluates a policy and reports who can actually read, write and list, plus the guardrails it is missing: HTTPS-only, SSE-KMS enforcement, and the VPC endpoint and organisation conditions above. It runs entirely in your browser, which is the point when the thing you are pasting is a live policy.