MealApp is offline-first, which means the client holds a copy of your data and syncs it back. That makes the isolation question sharper than usual: it is not just whether an endpoint checks who you are, it is whether anything reaching the database can be trusted to say who it is for.
The bug I kept writing
The obvious approach is a check at the top of every handler: read the session, compare the user id, reject if it does not match. It works. It also has to be written correctly in every handler, forever, including the one you add at midnight because a screen needs one more field.
I did not forget the check. What I did, more than once, was write a query that filtered by something adjacent — the meal plan id rather than the user id — on the assumption that owning the plan had already been established upstream. Sometimes it had. The bug was never a missing guard, it was a guard proving something slightly different from what the query then relied on.
Moving the rule into the database
Row-level security puts the predicate on the table itself. A policy says which rows a role may see, and Postgres applies it to every query touching that table, regardless of which endpoint issued it or what the query looked like.
The 17-table schema has policies on every user-scoped table. A query that forgets to filter by user does not leak — it returns nothing, because the rows were never visible. The failure mode changed from silently wrong to obviously empty, and obviously empty is something you notice in development.
Revoke first, then grant
The related move, on Kudi Kitchen's review system, was revoking default grants entirely. Anonymous and authenticated roles have zero access to those tables; only the service role can touch them, through Edge Functions that do the checking. Nothing is reachable because nobody remembered to lock it — the default is closed and access is added deliberately.
Other invariants went the same way. Freemium entitlements resolve through an atomic Postgres function rather than read-then-write in application code, so two concurrent purchase attempts cannot both see an unclaimed entitlement. Business rules that must not race live where the transaction is.
What this does not solve
RLS is not a substitute for authentication — something still has to establish who the request is from, and if that is wrong the policies faithfully enforce the wrong thing. It also does not help with rules that are not expressible per-row.
What it does is move a rule from somewhere it must be repeated to somewhere it is stated once. My endpoints still check things. The difference is that when one of them is wrong, it is now wrong in a way that shows up as no data rather than someone else's.