Engineering · Part 12 · one principle, four layers

Default-deny, everywhere

securityplatform

Reference documentation for a single security principle applied at every layer of a web service: default-deny. Access is closed unless something explicitly opens it, so that forgetting — the most common way security holes appear — fails safe.

Most breaches are not clever attacks; they are omissions. A developer adds a view and forgets the login check. A firewall rule is left open "temporarily". A config default fills in a value nobody chose. The unifying defence is to invert the default everywhere: make the safe state the one you get by doing nothing, so that a mistake locks a door rather than leaving one open. This document is that principle applied at four layers — the view, the network, administrative access, and configuration — each following the same rule.

Layer 1 — every view is closed unless opened

The classic web vulnerability is an endpoint that should have required login and didn't, because the decorator was forgotten. Remove the possibility: make login required by default for every view, and force the developer to explicitly mark the handful that are public.

# settings.py — the framework's project-wide login gate, added once
MIDDLEWARE = [
    ...
    'django.contrib.auth.middleware.LoginRequiredMiddleware',   # default-deny
]

# a public view must opt OUT, visibly:
@login_not_required
def landing(request): ...

Now the failure mode has flipped. Forget to annotate a public page and it is merely login-walled — an annoyance you notice immediately in testing. Forget to protect a private one and… nothing bad happens, because it was already protected. The dangerous omission is no longer possible; only the harmless one is. Audit becomes trivial too: the security-relevant surface is exactly the short list of @login_not_required marks, which you can read in one grep, rather than an unbounded set of views you must prove each protects itself.

Layer 2 — the network admits almost nothing

Apply the same inversion to the firewall. A security group starts denying everything; you open the two ports the service actually needs inbound, and — the part people skip — you lock egress too:

Ingress:
  - tcp 80  from 0.0.0.0/0    # ACME challenge + redirect to 443
  - tcp 443 from 0.0.0.0/0    # the app (and inbound webhooks)
  # no port 22. none.
Egress:
  - tcp 443                   # everything the box calls out to — object storage,
                             #   email API, payment API, AI provider, ACME — is HTTPS
  - udp/tcp 53                # DNS
  # nothing else leaves.

The same inversion holds with no cloud in sight. A self-hosted box gets it from one declarative nftables file that owns the whole host policy — INPUT policy drop, then loopback, established/related, ICMP, 80/443, the WireGuard UDP port, rate-limited SSH — and the tunnel gets its own default-deny: the hub's FORWARD chain is policy drop with exactly one accept, wg0 → wg0 within the tunnel subnet, so a compromised spoke can reach other spokes (by design) but never route through the hub to anywhere else. WireGuard itself is a third allowlist: a peer's AllowedIPs pins which tunnel addresses its key may source, and membership is nothing more than the hub's peer list — no key in the file, no packet decrypted.

Inbound denying-by-default is routine; egress denying-by-default is the valuable, skipped half. A box that can only make outbound connections on 443 and 53 is dramatically less useful to an attacker who lands on it: no arbitrary reverse shell to a random port, no exfiltration over a side channel, no fetching a second-stage payload from anywhere that is not already an HTTPS service you use. Achieving it takes one deliberate design choice — send email over the provider's HTTPS API, not SMTP — precisely so the egress can stay 443-only, since SMTP would force open port 25 or 587. The security posture and the "email via API" decision are the same decision. (Because dependencies are baked into the machine image and shipped as prebuilt artifacts, the box also needs no package-index or git egress at boot — see Parts 5 and 6.)

Layer 3 — no standing door for administrators

The most over-provisioned access in most deployments is the operator's own: an open SSH port and long-lived keys, present at all times for the rare occasion someone needs a shell. Close it. There is no port 22 and no SSH key management; administrative shell access goes through the cloud's session-manager service instead — an outbound-initiated, IAM-authorised, audited channel that needs no inbound port at all.

The wins compound. There is no inbound shell port to attack or misconfigure; there are no SSH keys to rotate, leak, or forget to revoke when someone leaves; every session is authenticated by the same IAM identities that govern everything else and is logged centrally. "How does an admin get a shell?" has an answer that adds zero attack surface — the opposite of the usual "leave 22 open just in case". For privileged users of the app itself, layer the same instinct: staff-level access additionally requires multi-factor authentication before an administrative page will load, enforced by middleware, so a stolen staff password alone opens nothing.

Layer 4 — configuration fails closed

The fourth default is the subject of its own document (Part 8), but it belongs in this list because it is the same idea: every required setting is read with no default, so a missing secret makes the app refuse to start rather than silently substitute an insecure value. A forgotten SECRET_KEY is a boot failure with a clear message, never a known-weak key quietly shipped to production. Absence fails safe here too.

The through-line

Notice that these are not four security features; they are one principle chosen four times:

LayerDefaultWhat "forgetting" now does
Viewslogin requireda page is over-protected (harmless), never under-
Network indeny; open 80/443 onlya service is unreachable (noticed), never exposed
Network outdeny; allow 443/53 onlya call fails (noticed), never a covert channel opened
Admin accessno standing port; session-manager + MFAno door left ajar to forget about
Configno defaults; refuse to starta boot failure, never an insecure fallback

The value is not any single control — each is ordinary in isolation — but the consistency. When every layer denies by default, a reviewer only has to check the small, explicit list of things that were deliberately opened, and a new engineer cannot create a hole by omission, only by an obvious commission that shows up in review as an added exception. Security stops depending on everyone remembering to add the check, and starts depending on the far rarer event of someone removing one.

The shape to copy

  1. Views: a project-wide login-required default; public pages opt out with a visible marker.
  2. Network: deny inbound but 80/443; deny outbound but 443/53 — and choose HTTPS-API email so egress can stay locked.
  3. Admin: no SSH port, no keys; session-manager for shells, MFA for privileged app pages.
  4. Config: no defaults, refuse to start (Part 8).
  5. The security-relevant surface is now exactly the list of explicit exceptions — auditable in a grep.

Part 13 takes one of those outbound calls — ACME — and shows how a disposable box keeps its HTTPS certificates across replacement.


← All posts