What Bubble's privacy rules become in real code
The translation is not the difficult bit. Reading years of accumulated rules and working out which ones are load-bearing is the difficult bit.
Lucian Lutas
Bubble privacy rules are the part of a migration that people assume is easy and then lose two weeks to. Not because the translation is hard. Because nobody knows what the rules currently say.
A privacy rule in Bubble is a condition attached to a data type that decides
which records a user can see, and which fields of those records come back at
all. They are enforced server-side, which is the important part: a field hidden
by a privacy rule is genuinely absent from the response, not hidden in the
browser. That is the behaviour you have to reproduce, and it is the reason you
cannot do this with a WHERE clause in your application code.
The equivalent in Postgres is row level security. The concepts line up almost one to one, which is the good news. The bad news is everything below the first heading.
The translation, one to one
A Bubble rule reads roughly as: for data type X, when this condition is true about the current user, they may find this record in searches and may see these fields.
A Postgres policy reads as: for table X, this expression decides which rows this role may select.
Here is the most common rule in every Bubble app, the one that says a user can only see their own records:
alter table projects enable row level security;
create policy "owners read their projects"
on projects for select
using (auth.uid() = owner_id);
That is the whole translation. auth.uid() is Supabase's equivalent of
Current User, and using is the condition. Once row level security is
enabled on the table, no query gets rows back unless a policy allows it, from
the API, from the client, or from a colleague poking at the database with the
anon key.
Field-level visibility is the piece people forget. Bubble lets a rule expose some fields and withhold others on the same record. Postgres does that with a view or with column privileges:
create view public_profiles as
select id, display_name, avatar_url from profiles;
revoke select on profiles from anon;
grant select on public_profiles to anon;
Two mechanisms instead of one checkbox. It is more code and it is more explicit, and after the first week nobody on the team misses the checkbox.
The four ways this goes wrong
The rules have never been read end to end. This is the real problem and it is not a technical one. Privacy rules accumulate over years. Some are load-bearing, some were added to fix a bug that no longer exists, and the person who could tell them apart has left. Every rule has to be read, understood and reproduced, and understood is the expensive word. Budget audit time for this, not migration time.
Default-allow becomes default-deny. A Bubble data type with no privacy rule is readable. A Postgres table with row level security enabled and no policy is readable by nobody. That inversion is a feature and it will still break your staging environment on day one, because the tables nobody thought about are exactly the tables nobody wrote policies for. Better to find that in staging than to discover in production that you have been shipping an unprotected table for two years.
Searches and direct access get conflated. Bubble distinguishes between finding a record in a search and viewing it when you already have the link. Postgres does not make that distinction natively, so a rule that relied on it needs to become explicit: usually a policy for the general case plus a signed, time-limited token for the share-by-link case. Anyone quoting your migration without asking whether you use this is guessing.
The service role bypasses everything. Supabase's service key ignores row
level security by design, because your server-side jobs need to. This is
correct and it is also the single most likely way to leak data in the new
stack. The rule is short: the service key never reaches the browser, never goes
in a NEXT_PUBLIC_ variable, and every server route that uses it does its own
authorisation check first. That last clause is the one that gets skipped.
Auditing yours before you commit
You can do this yourself in an afternoon, and you should, whether or not you migrate. It is the cheapest useful thing in this article.
for every data type
- List the rules, in order, and who each one is written about
- Write in plain English what the rule intends, then check the condition actually does that
- Mark every rule as load-bearing, redundant, or nobody knows
- Find the data types with no rule at all and decide whether that is deliberate
- Note every field that is hidden rather than the whole record
The third item is the one that produces value. In most apps a meaningful share of the rules turn out to be redundant, and a redundant rule is a rule you do not have to reproduce, test or maintain. The ones marked nobody knows are your actual risk register, and they are worth resolving before anyone writes a line of Postgres.
The last item matters because field-level rules are invisible in the app. The app just works, and nobody notices that one field has been quietly absent from one role's responses since 2023. Reproduce it faithfully or you will hand a salary column to the wrong role on cutover day.
What you gain
Policies are text. They live in a migration file, they go through code review, they diff, and they get tested. You can write a test that asserts user A cannot read user B's rows and run it on every deploy. That is not available to you inside a no-code editor, and for anyone in a compliance conversation it is most of what the conversation is about.
You also get to see all of them at once, which is the thing that makes the audit above so uncomfortable the first time.
What you take on
Row level security is not a safer place to be. It is a more inspectable one. You can write a permissive policy in Postgres just as easily as you can leave a data type unprotected in Bubble, and the failure mode is identical. What changes is that the mistake is now visible in a file that a second person can read, rather than in a settings panel that nobody opens.
Policies also run on every query. A policy that calls a function or joins another table adds work to each row considered, and on a large table that is measurable. It is fixable with indexes and with keeping the policy expression simple, but it is real, and it is a cost you did not have before.
None of this is a reason to migrate on its own. Compliance is, sometimes: if SOC 2 or HIPAA has entered your conversations, "our authorisation rules are version-controlled and tested" is an answer, and a screenshot of a settings panel is not. If nobody is asking, this section is not your reason. Read when to leave Bubble, and when not to for the signals that are.
If you want your own rules read properly rather than guessed at, that is the first thing the audit does.