Skip to content
duarteclemente
Go back

Sharing a Database Is Sharing an Attack Surface

A product I built shared a single Postgres database with a self-hosted, third-party AI document service. One project, one database, two applications sitting side by side. It’s a common shape and it’s usually fine. The moment it stops being fine is the moment you realise a shared database means a shared attack surface — the other service’s tables are now part of yours, whether you wrote them or not.

This is a short story about auditing a dependency’s tables as if they were my own, because they were.

Table of contents

Open Table of contents

The moment the surface got bigger

When you bolt a second application onto the same Postgres project, you don’t just gain its features. You inherit its schema. Every table that service creates lives in the same database, behind the same front door, reachable by the same clients. If that service is sloppy about who can read its rows, your users are the ones standing in front of the open door.

So I did the boring thing and audited it. I went table by table through what the AI service had created and checked one column: is row-level security on?

It wasn’t. The service had 23 tables. Fifteen of them had RLS switched off entirely.

Why “RLS off” is louder than it sounds

In a hosted Supabase project, tables are exposed over an auto-generated REST API — PostgREST — and the public anon key that fronts that API is the same key for everyone using the project. It’s not a secret. It ships in the browser. It’s meant to ship in the browser. The thing standing between “anyone with that key” and “your rows” is row-level security.

RLS off on an exposed table means no gate. Any authenticated browser holding that public key could, in principle, query those 15 tables straight over the REST API and read privileged documents out of them. Not through some exotic exploit — through the API the platform hands you for free.

Nobody had walked through it. There was no breach. But “nobody has yet” is not a security posture. The door was open; the fact that no one had wandered in was luck, not design.

The fix I didn’t reach for

The obvious move is to go fix the dependency. Fork it, patch the migrations, add the policies the authors should have added, ship your corrected version.

Don’t. This service ships under a copyleft license — AGPL — and forking a copyleft dependency to carry a private patch is exactly the kind of maintenance debt that looks cheap on day one and bills you every time upstream moves. You’d be babysitting a fork forever to fix something that isn’t really about the code.

The exposure wasn’t in the service’s logic. It was in the database posture. So I fixed it at the database, where I actually had authority, without touching a line of the dependency.

Deny by default, zero policies

Here’s the trick, and it’s almost too simple to feel like a fix. On every one of those exposed tables I enabled RLS — and added zero policies.

RLS with no policies isn’t “allow everything.” It’s the opposite. It’s “nobody gets a row unless something explicitly grants it.” Turning RLS on flips the table from open to closed; adding a policy is how you’d carve out a specific allowance. Add none and you’ve said no one, full stop, over the public API. The service’s own backend, which connects with elevated credentials, sails straight past RLS and keeps working exactly as before.

alter table public.some_service_table enable row level security;
-- no policies. that's the point.

One alter table per exposed table. No forked repo, no patched migration, no upstream to track. And it wasn’t even a novel idea — the service already ran RLS-on-with-no-policies on its other 8 tables. The 15 unlocked ones weren’t a design choice; they were the ones someone forgot. I just made the schema consistent with the intent that was already sitting right there in the same database.

Shared database, shared responsibility

The lesson isn’t “AGPL services are dangerous” or “don’t self-host.” Both of those are fine. The lesson is smaller and more uncomfortable: the security boundary of your application is the whole database, not the part you wrote.

When you share a Postgres project with a third-party tool, you don’t get to treat its tables as its problem. They’re exposed through the same API, fronted by the same public key, and read by the same browsers. An attacker doesn’t care which team authored the table with the sensitive rows. If it’s reachable and it’s open, it’s yours to answer for.

So audit the dependency’s RLS like it’s your own code. Because from the attacker’s side of the door, it is.

Slides: the condensed version is in a short deck — download the PDF.

When did you last check the RLS on the tables you didn’t write?


Share this post:

Previous Post
A Time Clock That Catches Labor Issues Before Payroll
Next Post
The LLM Extracts the Numbers. It Never Calculates Them.