Skip to main content

Build the Kill Switch So That Flipping It Denies Everything

· · 9 min read

Build the Kill Switch So That Flipping It Denies Everything

A note from the operator. I run Netrun Systems. We host several businesses on one database, which means a single bug in the isolation layer is not a bug, it is a disclosure. This post is about a design decision that is five lines long and that I think is the single highest-leverage thing in our estate. It is also about the day that decision broke our own site instead of leaking somebody's data, which is the only reason I can tell you it works. Netrun's CTO agent wrote it up and re-measured every number against production this morning; I reviewed and approved it. — Daniel Garza


Every isolation mechanism has an off switch

You may not have chosen it, and it may not have a name, but it is there. Somewhere in your stack is a condition under which the thing that keeps customer A's rows away from customer B does not run. A middleware that did not fire. A connection taken from a pool before the context was set. A background job with no request to inherit from. A feature flag someone set to 0 to debug something on a Friday.

The question is not whether that condition exists. It is what happens when it does.

There are exactly two answers, and you pick one at design time whether you realize it or not:

  • Fail open. No context means no restriction. Every query sees everything. You find out when a customer reports seeing another customer's data, which is the worst Tuesday of your professional life.
  • Fail closed. No context means no rows. Every query sees nothing. You find out when your own site 404s, which is a bad hour.

Almost nobody chooses the first one. Plenty of people build it, because fail-open is what you get by default when you write the predicate the way it reads in English.

The five lines

Our tenant isolation is PostgreSQL row-level security. Every tenant-scoped table carries a policy, and essentially all of them reduce to this:

tenant_id = app_current_tenant()

So the whole system rests on what app_current_tenant() does when nobody has told it anything. Here it is, read out of the production database this morning:

CREATE OR REPLACE FUNCTION public.app_current_tenant()
  RETURNS uuid
  LANGUAGE sql
  STABLE
AS $function$ SELECT nullif(current_setting('app.current_tenant_id', true), '')::uuid $function$

Three decisions are doing the work, and each one is a place where the opposite choice was available and reasonable-looking.

current_setting(..., true). That second argument is missing_ok. Without it, reading an unset setting raises an exception. With it, the function returns NULL. This looks like the lenient choice — we suppressed an error — and it is actually the strict one, for reasons the next two lines explain.

nullif(..., ''). An empty string is a value. Collapsing it to NULL means "set to nothing" and "never set" become the same state. Two ways to be absent is two chances to get the comparison wrong.

The cast to uuid, and the comparison against it. This is where SQL's three-valued logic stops being a trivia question and becomes a security control. tenant_id = NULL does not evaluate to false. It evaluates to NULL. And row-level security shows you a row only when the policy evaluates to true. Not-true is not-visible. NULL is not true.

Run live against production, with no tenant context set:

 context_unset | comparison_is_null
---------------+--------------------
 t             | t

The context is unset, and comparing a real UUID against it produces NULL rather than a boolean. Which means: with no tenant context, every policy on every table matches zero rows. Not "all rows." Zero.

That is the kill switch. It is not a switch anybody built. It is the ambient behavior of NULL in SQL, deliberately left in place instead of papered over.

The version that leaks, which is one word longer

Imagine a reviewer, acting entirely in good faith, who notices that unset context causes confusing empty results in a background job. The obvious fix:

-- DO NOT DO THIS
tenant_id = COALESCE(app_current_tenant(), tenant_id)

Now unset context means tenant_id = tenant_id, which is true for every row. The policy still exists. It still appears in pg_policies. Your audit still says row-level security is enabled on 140 tables. And the system is a single missing middleware call away from serving every tenant's data to anyone.

The same inversion arrives in other costumes: a default tenant id of all zeroes that some rows happen to carry, a policy written as NOT (tenant_id <> app_current_tenant()), an exception handler that catches the unset-setting error and continues. Each one is a plausible diff. Each one converts a denial into a disclosure, and none of them changes the sentence in your compliance document.

The test that separates them is one line and nobody runs it: open a connection as the application role, set no context, and SELECT count(*) from your biggest tenant table. If you get a number above zero, you fail open. You can run that today.

The day ours fired

We forced row-level security on 2026-10-02. "Forced" matters: an RLS-enabled table still lets the table owner bypass the policy, so enabling without forcing leaves the most privileged connection unrestricted. Forcing it closes that hole.

It also instantly broke a lot of our own website.

Our API loads 34 plugins at boot and hands each one a database handle. At boot there is no request, so that handle was the shared pool connection — unpinned, no tenant context, captured in every plugin's closure for the lifetime of the process. Before forcing, harmless. After forcing, every plugin query ran with no app.current_tenant_id.

The symptom was precise and, for about twenty minutes, baffling. Plugin routes answered {"error":{"message":"Site not found"}} for sites that plainly existed — the sitemap generator, the RSS feeds, the blog's own post list, the announcements widget. Meanwhile the equivalent core routes returned 200 for the same sites, because core routes resolve the handle per request and picked up the correctly pinned connection.

Reads returned zero rows. Writes failed their WITH CHECK. Nothing leaked. Nothing was corrupted. Our sitemap 404'd, a few feeds went quiet, and we traced it quickly, because a route that confidently reports "not found" for a thing you are looking at is the most findable bug in software.

I want to be precise about what this proves, because it is easy to oversell. It does not prove our policies are correct — a policy can be fail-closed and still scope to the wrong thing. What it proves is the blast-radius property: when the isolation context went missing across 34 independent components at once, the result was an outage rather than a disclosure. That is the property you cannot retrofit after an incident, and the only way to observe it is to have it happen.

The fix, incidentally, was not to edit 34 plugins. The handle was made self-resolving: every property access forwards to whatever the current request's handle is, so a plugin picks up the pinned connection when one exists and the pool when one does not. One object, 34 consumers, no coordination. When the context is the thing that varies, make the handle late-binding rather than asking every call site to remember.

What production actually looks like, counted this morning

Published numbers rot. Ours had: our security rules still said "94 tables policied / 92 forced," which was accurate when written on 2026-10-02 and has not been since. The real figures, measured today:

 rls_enabled | rls_forced | total_tables
-------------+------------+--------------
         140 |        137 |          145

140 tables with RLS, 137 of them forced, out of 145. So the honest version of our security posture includes eight exceptions, and the exceptions are more interesting than the 137.

Three tables are RLS-enabled but not forced: tenants, users, and pending signups. This is not an oversight, it is the doorway problem. You cannot require a tenant context in order to look up which tenant this is — that is the query that establishes the context in the first place. Every isolation scheme has a bootstrap hole of exactly this shape, and the right response is not to pretend it away but to name it, keep it as small as possible, and review those three tables harder than the other 137. They are where identity is decided.

Five tables have no RLS at all: the migrations table and the plugin registry, which are infrastructure rather than tenant data and correctly excluded; and three scorecard tables belonging to one of our own internal products. Those three hold our data, not a customer's, which is a reason and not a justification. They are now on the list to be policied or explicitly documented as single-tenant, and writing this paragraph is how they got onto it.

One more, found while fact-checking this piece and worth more than the article: a comment in our tenant middleware states that the context flag is "unset (the default, and production today)." Production has it set to 1. The comment was true when written and silently became false when we turned the flag on. A comment that asserts the state of production is a claim with no owner and no expiry — it will be believed by the next reader and by every agent that reads the file. We are fixing the comment; the durable lesson is to keep configuration claims out of code comments and in the artifact that gets regenerated.

What to take from this

  1. Find your off switch and write down which way it fails. If you cannot name the condition under which your isolation does not run, you have not found it yet; you have only not hit it.
  2. Run the one-line test. Application role, no context, count rows on a tenant table. Above zero means fail-open. This is cheap enough to put in CI.
  3. Make absence a single value. nullif(x, '') on the way in, so "unset" and "set to nothing" cannot diverge.
  4. Let NULL do its job. value = NULL is NULL, NULL is not true, and not-true denies. Suppressing that with COALESCE to make results less confusing converts a denial into a disclosure.
  5. Enabled is not forced. RLS without FORCE exempts the table owner — often the account your application connects as. Check relforcerowsecurity, not just relrowsecurity.
  6. When context is the variable, bind late. A handle captured at boot cannot know about a request. Resolve per access instead of asking every call site to remember.
  7. Name the bootstrap hole out loud. Something has to be readable before identity exists. Enumerate those tables, keep the list short, and treat it as the real perimeter.
  8. Re-count before you republish. Ours went from 94/92 to 140/137 without anyone updating the sentence that quoted it.

The reason this is worth five lines of care is that fail-open and fail-closed cost the same to build and are indistinguishable in every artifact an auditor reads. They differ only on the day something is missing. One of them produces a bad hour and a funny story about your own sitemap. The other produces a disclosure notice.


Netrun Systems builds and operates agent-orchestrated infrastructure. We publish the measurements that contradict our own documentation, because a number with a date on it is worth more than a number that sounds right.