DenyFirst

DenyFirst blog

Your security page is not a security scan

2026-08-10

App-building platforms have started shipping a security page with every app you publish. It lists the controls running on your app — auth is on, row-level security is enabled, traffic is encrypted — and you can show it to a customer or an investor.

This is a good thing. It is also, quietly, half an answer.

A security page tells you what is configured. It cannot tell you what is reachable. Those sound like the same question. They come apart constantly, and the gap between them is where almost every real leak lives.

The gap, in one line of policy

Here is a Supabase policy that makes a security page say "row-level security: enabled."

create policy "read" on user_connections
  for select using (auth.uid() is not null);

RLS *is* enabled. The dashboard is not lying. The checkbox is honestly ticked.

And any person who signs up for your app can read that entire table.

The policy checks whether the caller is logged in. It never checks whether the row belongs to them. Being authenticated is not the same as being the owner, and a policy that confuses the two is a policy that hands every user everyone else's data. The Firebase dialect of the same mistake — allow read: if request.auth != null — is something we've written about before.

No configuration page detects this, because nothing about it is misconfigured in a way a configuration page can see. RLS is on. A policy exists. The only way to find it is to actually ask: *can an account that owns nothing read something?*

Why this got more expensive last month

Until recently, that bug leaked your app's data. Bad, sometimes catastrophic, but bounded by whatever your app stores.

Then the builders added per-user integrations. Your app can now let each user connect their own HubSpot, Google, Slack or Salesforce account — and the pitch, accurately, is that there is *nothing for you to rebuild*. You don't write the access-control logic. That's the feature.

But those connections have to live somewhere, and where they live is an ordinary table in your ordinary database, holding ordinary columns called access_token and refresh_token.

So the same one-line policy mistake stops leaking rows and starts leaking working credentials to other people's businesses. Not "an attacker learned some records." An attacker with a free account on your app gets a live key to a stranger's CRM. You are now the breach vector for companies that have never heard of you, and no rotation you perform fixes their exposure — only theirs does.

The severity went up an order of magnitude. The bug did not change at all.

Configuration questions vs exposure questions

It's worth being precise about which is which, because the first kind is what gets audited and the second kind is what gets breached.

| A configuration question | An exposure question | |---|---| | Is RLS enabled? | Can a signed-in stranger read this table? | | Do we have auth? | Does the API check ownership, or only login? | | Are secrets in env vars? | Is the service key in the client bundle? | | Is the bucket private? | Does this URL return the file without a session? |

Everything in the left column can be true while everything in the right column is also true. A security page answers the left column. That is genuinely useful — it is a real artifact, honestly produced, and worth showing people.

Just don't read it as an answer to the right column, because it was never asked.

How to actually check

You don't need a product for this. You need one throwaway account and five minutes.

1. Sign up for your own app with an email you've never used. Connect nothing. Add nothing. This account owns exactly zero rows. 2. Open the browser devtools network tab, grab that session's token, and ask your own API for the tables you care about — starting with whichever one holds integration tokens. 3. Count what comes back.

An account that has connected nothing should see zero rows in a table of connections. If it sees any, they belong to someone else, and you've just reproduced the bug from the outside in the time it takes to make a coffee.

That third step is the whole test. Not "is RLS on" — *how many rows can a nobody see*.

If the answer is not zero, treat it as a credential compromise before you treat it as a policy bug: revoke and re-issue the third-party tokens, on the assumption that every row was read, and only then fix the policy. A corrected using clause does not un-read a token somebody already took.

What we do

DenyFirst runs exactly that check, and it is the reason we exist: we answer the right-hand column. We look at what your app actually serves to a stranger — a count, never your data — and we tell you when the number should have been zero and wasn't.

We'll also tell you when it *is* zero, with a record of what we tried, because "we found nothing" and "we couldn't check" are different sentences and only one of them is good news.

Your security page and a scan are not competitors. One says what you meant to build. The other says what you actually shipped. You want both, and you should be suspicious of anyone who tells you the first one is enough.


← All posts