skip to content

How would you write an S3 bucket policy that denies every request coming from outside your AWS Organization, and what mistake in that policy can lock you out of your own bucket?

level: seniorimportance: should knowfreq 38%

answer

  1. guardrail, so Deny not Allow
  2. the key AWS fills in for you
  3. services call without an org id
  4. NotPrincipal is a trapdoor
  5. root can still delete the policy

basics

~20 s

Add a Deny statement on all S3 actions when aws:PrincipalOrgID does not equal your organization ID. The trap is that AWS service principals do not carry that key, so log delivery and other service writes break unless you exempt them with aws:PrincipalIsAWSService.

solid answer

~50 s

The idiom is a broad `Deny` rather than a narrow `Allow`: deny `s3:*` on the bucket and its objects when `StringNotEquals` on `aws:PrincipalOrgID` does not match your organization ID. Because an explicit deny beats every allow, it holds no matter what a future bucket policy statement or IAM policy says — that is the point of a guardrail. The classic self-inflicted outage is that AWS services calling on your behalf — CloudTrail or Config delivering logs, a replication role in another account, sometimes CloudFront reading the origin — do not present `aws:PrincipalOrgID`, so the deny catches them and log delivery silently stops. Add `Bool: {"aws:PrincipalIsAWSService": "false"}` to the condition so service principals fall outside the deny, and test the policy before applying it. If you do lock yourself out, the bucket-owning account's root user can delete the bucket policy.

code

json · 14 lines
json
{
  "Sid": "DenyOutsideMyOrg",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": [
    "arn:aws:s3:::regulated-data",
    "arn:aws:s3:::regulated-data/*"
  ],
  "Condition": {
    "StringNotEquals": {"aws:PrincipalOrgID": "o-abc123example"},
    "Bool": {"aws:PrincipalIsAWSService": "false"}
  }
}

go deeper

for a junior

Know that a bucket policy can carry conditions, and that aws:PrincipalOrgID is the key that means "a principal in my AWS Organization". Recognise that Deny beats Allow.

for a middle

Write the statement correctly: Deny, Principal "*", both bucket and object ARNs, StringNotEquals on the org ID, and explain why the two condition operators are ANDed.

for a senior

Anticipate the blast radius — service-principal log delivery, replication roles, forgotten consumers — inventory callers from CloudTrail first, stage the rollout, and know the root-user recovery path exists but is break-glass.

for a principal

Decide where the control lives: a per-bucket deny for one high-value dataset versus an organisation-level policy that applies everywhere by default, plus the exception process and the detective controls that prove it is still in force.

## Why a Deny and not an Allow You can express "only my organisation" two ways. An `Allow` limited by `aws:PrincipalOrgID` grants access to org principals but leaves the door open for someone to add a second, broader statement later. A `Deny` conditioned on *not* being in the organisation is a guardrail: explicit deny beats every allow, in the bucket policy, in identity policies, and anywhere else. For a control you intend to be non-negotiable, write the deny. ```json { "Version": "2012-10-17", "Statement": [{ "Sid": "DenyOutsideMyOrg", "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::regulated-data", "arn:aws:s3:::regulated-data/*" ], "Condition": { "StringNotEquals": {"aws:PrincipalOrgID": "o-abc123example"}, "Bool": {"aws:PrincipalIsAWSService": "false"} } }] } ``` Both condition blocks must be satisfied for the deny to apply, because multiple condition operators in one statement are ANDed. So the statement reads: deny anyone who is neither in my organisation nor an AWS service principal. ## The condition keys, precisely - `aws:PrincipalOrgID` is the organisation ID of the account making the request. It is populated for IAM principals in member accounts of the organisation, and is a global key AWS resolves — you never set it yourself. - `aws:PrincipalIsAWSService` is `true` when an AWS service principal is calling. This is what saves log delivery. - `aws:PrincipalAccount` and `aws:SourceAccount` are for narrower, account-specific rules; `aws:SourceArn` pins the specific resource on whose behalf a service is calling and is the right companion when you do want to allow a service but only for one trail or one distribution. A related fence: `aws:SourceVpce` restricts requests to those arriving through a named VPC endpoint. Combining that with the org deny gives you "my org, and only over the private path" — but be aware you are then also excluding console access from outside that network path. ## The lockouts, in order of how often they happen **1. Service principals.** CloudTrail writing trail files, Config delivering snapshots, ELB or CloudFront writing access logs — these calls are made by an AWS service, not by an org principal, so `aws:PrincipalOrgID` is absent and `StringNotEquals` evaluates true. The deny fires and delivery stops. It fails quietly: nobody notices missing audit logs until an incident. The `aws:PrincipalIsAWSService` exemption is the fix, ideally combined with `aws:SourceArn` so you allow only the specific trail or distribution. **2. Cross-account roles you forgot.** S3 replication uses a role in one account writing to a bucket in another; a data-science account reads exports; a vendor's ingest role writes files. If any of those live outside the organisation, the deny catches them. **3. Denying yourself.** A statement built from `NotPrincipal` — "deny everyone who is not this list" — is the sharpest edge in IAM. `NotPrincipal` with `Deny` matches every principal not enumerated, including your administrators, your CI role, and the console session you are using to fix it. Prefer a `Condition`-based deny over `NotPrincipal`; it expresses the same intent without the trapdoor. ## Recovering S3 leaves a way back: sign in as the bucket-owning account's **root user** and delete the bucket policy with `DeleteBucketPolicy`. That is a genuine break-glass event — it requires the root credentials and MFA that you keep locked away, and it should generate an alarm. Everything about that recovery path is an argument for testing the policy first. ## How to test before you ship it - Apply it in a non-production account against a copy of the bucket, then exercise every consumer: replication, log delivery, batch jobs, the CDN. - Use the IAM policy simulator for the principals you know about, and IAM Access Analyzer's policy validation for the statement itself. - Turn on S3 server access logging or CloudTrail data events *before* the change so you have a baseline of who actually calls the bucket. The consumers you forgot are visible there and nowhere else. - Roll it out to one bucket, watch a full batch cycle including weekly and monthly jobs, then generalise. ## The scaling answer One bucket policy per bucket does not scale as an organisational control. The same condition belongs in a Service Control Policy or a resource control policy at the organisation layer, where it applies to every bucket in every account without anyone having to remember. The per-bucket deny is the right tool for a single high-value bucket, not for the estate.

  • Why prefer a Deny conditioned on aws:PrincipalOrgID over an Allow limited to the same key?
    An allow can be widened by any statement added later, in this policy or in an identity policy; a deny cannot be overridden by anything. If the intent is "this data never leaves the organisation", that must survive the next engineer's well-meaning allow, so it has to be expressed as an explicit deny.
  • You have applied the deny and CloudTrail log delivery to the bucket stopped. What is happening?
    CloudTrail writes as an AWS service principal, which does not carry `aws:PrincipalOrgID`, so the StringNotEquals condition is true and the deny fires. Exempt service principals with `"Bool": {"aws:PrincipalIsAWSService": "false"}` in the condition, and tighten it further with `aws:SourceArn` so only your own trail is permitted.
  • Why is NotPrincipal with Deny considered dangerous in a bucket policy?
    Because it denies every principal you did not enumerate — including administrators, CI roles and the session you would use to repair it — and enumerating principals correctly is exactly what people get wrong. A condition-key deny expresses the same intent while still evaluating your own principals normally, so it has no trapdoor.
  • Where does this control really belong once you have more than a few buckets?
    At the organisation layer, as a Service Control Policy or a resource control policy applying the same condition across every account. A per-bucket policy depends on someone remembering it for each new bucket; an organisation-level control applies by default and cannot be removed by an account administrator.

saying these in an interview costs you the question

  • Assumes AWS service principals carry aws:PrincipalOrgID
  • Uses NotPrincipal with Deny to express "only us"
  • Thinks an Allow with the org condition is equally strong
  • Believes a bad bucket policy is unrecoverable
  • Ships the deny without inventorying current callers

context