← back to the garden

evergreen

Deny by Default: Nothing Is Reachable Unless You Say So

planted · April 12, 2026
#security-first-aws#security#aws#cloud#day-0#terraform

Most exposure is not a decision. Nobody sits down and chooses to make a bucket world-readable. It happens because the default leaned open, a setting was left at its permissive value, and the resource went to production before anyone checked. Deny-by-default is the discipline of flipping that around: the starting position is closed, and any reachability is something you added on purpose and can point to.

Stated as a principle, it is this: nothing is reachable unless explicitly allowed, private is the default, and there is exactly one controlled path in. Everything else says no. The work is making "no" the resting state, so that openness requires an affirmative act you would notice in review, rather than an omission you would not.

Private at every level, not just one

Object storage is where this goes wrong most often, so it is the clearest place to get it right. A common assumption is that one setting makes a bucket private. In practice there are several independent ways a bucket can become public: a public ACL on the bucket, a public ACL on an individual object, a bucket policy that grants access broadly, or account-level defaults. Closing one door and leaving the others open is not private. It is private until the next person, or the next automated tool, opens one of the others without realizing it.

So you close all of them, on every bucket, from creation:

resource "aws_s3_bucket_public_access_block" "site" {
  bucket = aws_s3_bucket.site.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Four flags, all on. Block public ACLs so one cannot be set. Ignore any that somehow already exist. Block public bucket policies so a policy cannot grant broad access. Restrict public buckets so even a policy that slips through is not honored. This is belt and suspenders on purpose. The whole point of deny-by-default is that no single mistake, in a policy, an ACL, or a future change, is enough to make the thing public. It would take someone deliberately removing this block, which is a change you would see and question.

Every bucket I create gets this, whether it holds a static website, uploaded files, or Terraform state. There is no bucket where "maybe public is fine" is the default, because the default is closed and staying closed costs nothing.

One front door, and it is the only way in

Closing the bucket raises an obvious question: if the storage is private, how does anyone read the website it holds? The answer is the second half of deny-by-default. You keep the origin private and put exactly one controlled path in front of it, and you make that path the only thing the origin will answer.

For a static site, that path is a CDN using origin access control. The browser talks only to the CDN, over your custom domain. The CDN, and nothing else, is allowed to read the private bucket, and the bucket's policy proves it by pinning access to that one specific distribution:

resource "aws_s3_bucket_policy" "site" {
  bucket = aws_s3_bucket.site.id
  policy = jsonencode({
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "cloudfront.amazonaws.com" }
      Action    = "s3:GetObject"
      Resource  = "${aws_s3_bucket.site.arn}/*"
      Condition = {
        StringEquals = {
          "AWS:SourceArn" = aws_cloudfront_distribution.site.arn
        }
      }
    }]
  })
}

The AWS:SourceArn condition is the load-bearing line. It does not say "CloudFront can read this bucket." It says "this exact distribution can read this bucket." Another CloudFront distribution, even one in your own account, cannot. There is one door, it has one key, and the key is bound to one holder. Direct requests to the storage endpoint get nothing, because they are not the door.

Origin access control is worth naming specifically over the older origin access identity it replaced. It signs each origin request with SigV4, which is what makes the tight SourceArn pin possible and what lets the same pattern work cleanly when the origin is encrypted with a customer-managed key. If you are building this today, use origin access control; the identity-based predecessor is legacy.

Pin the floor the connection stands on

Reachability is not only about who can reach the origin. It is also about the quality of the connection the outside world is allowed to make. Left to defaults, a distribution may accept old TLS versions for the sake of ancient clients. Deny-by-default applies here too: you refuse the weak options rather than tolerate them.

viewer_certificate {
  acm_certificate_arn      = aws_acm_certificate.site.arn
  ssl_support_method       = "sni-only"
  minimum_protocol_version = "TLSv1.2_2021"
}

The minimum protocol version sets a floor. Anything below modern TLS is simply not offered. Paired with a viewer policy that redirects every plain HTTP request to HTTPS, there is no unencrypted and no obsolete-encryption path to the site. A visitor connects on current TLS or does not connect. That is one more default flipped from permissive to closed.

The same logic applies to an API, with a written reason

The static-site case is clean because the origin can be fully private. Sometimes the front door itself has to accept public traffic, and deny-by-default still has an answer: scope the door as tightly as the situation allows, and reject the obvious junk before it reaches anything that matters.

On an API I run, the gateway does not accept requests from the whole internet. Its resource policy allows only one upstream's address ranges, so traffic that has not come through the intended proxy is refused at the edge, before it reaches any function. And because an allowlist that silently empties itself is worse than no allowlist, the Terraform guards against exactly that:

lifecycle {
  precondition {
    condition     = length(local.cloudflare_cidrs) > 0
    error_message = "CIDR fetch returned no ranges; refusing to apply an empty allowlist."
  }
}

That precondition is deny-by-default reasoning made explicit. If the list of allowed ranges comes back empty because an upstream format changed, the apply fails loudly rather than shipping an allowlist that matches nothing, or worse, one that a careless fix might flip to matching everything. The safe failure is the closed one. You would rather the deploy stop than the door swing open.

What "closed by default" actually feels like

The reward for all of this is that exposure becomes visible. When private is the default at every level, when there is one pinned door, when the TLS floor is set and the allowlist refuses to empty itself, then anything reachable is reachable because a specific line of code says so. You can read the openness. You can point at it in review and ask whether it should be there.

The alternative, the one deny-by-default is meant to prevent, is discovering your exposure from the outside, after someone else found the door you did not know was open. Closed-by-default is more lines up front and far fewer surprises later. Make "no" the resting state on day 0, and every "yes" after it is a decision you can account for.


Previous: Least Privilege Starts at Resource One

Next in this series: The More You Log, the More You Leak

The same decision on GCP: Deny by Default: Nothing Is Reachable Unless You Say So