AppSavvyBook a call
Bubble Migration

Translating Bubble Privacy Rules to Postgres RLS Policies

How Bubble privacy rules map to Postgres row-level security. Common patterns, gotchas where Bubble's rules silently fail, and a worked example with Supabase.

Will Driscoll9 min read

Privacy rules are the most-undertested feature in most Bubble apps. They look like they're protecting data; they're often only partly protecting it because the UI happens to not expose the protected fields. Migration is the moment when this fragility becomes visible.

This article walks through how Bubble's privacy rules map to Postgres row-level security (RLS) policies, where the translation isn't 1:1, and the patterns we use most often at AppSavvy when migrating apps to Supabase.

What Bubble privacy rules actually do

Bubble privacy rules are evaluated whenever a workflow or the data API reads or writes a Data Type record. Each rule defines:

  • A condition under which the rule applies (e.g. "When current user's role is admin")
  • A set of fields that are visible/editable when the condition is true
  • Behaviours like "Find this in searches", "View all fields", "Make changes", "Auto-bind"

A Data Type can have many rules. They're evaluated top-down, and the first matching rule wins.

The catch: privacy rules apply at the data layer. But Bubble's UI has its own implicit "rules" about which fields it shows. Most apps end up relying on both - some fields are properly protected by privacy rules; others happen to be safe because the UI doesn't expose them.

When you migrate to Postgres + RLS, this distinction stops being optional. The RLS policy is the only protection. There's no UI-implicit safety net.

What Postgres RLS does

Postgres RLS policies are SQL expressions that run on every SELECT, INSERT, UPDATE, or DELETE against a table. The expression has access to the current "role" (in Supabase, the authenticated user's JWT claims) and can reference any columns of the row.

A policy looks like:

Allow the row if auth.uid() = user_id

That policy, applied as a SELECT policy on the bookings table, means a user can only SELECT rows where the user_id column matches their authenticated user ID.

Critically, RLS is deny by default. If no policy allows the operation, it's blocked. Bubble's model is the opposite - allow by default, then layer on privacy rules to restrict.

This default difference is the most important thing to internalise about the migration.

Mapping the common patterns

Pattern 1: "User can only see their own records"

This is the most common pattern. In Bubble, a privacy rule like "When Current User is This thing's User" applies to a Data Type that has a User field.

In Postgres, it becomes:

SELECT policy: allow if auth.uid() = user_id

The table needs a user_id column. If you have it under a different name (owner, created_by, etc.), use that. The RLS policy expression is just SQL.

Pattern 2: "Admins can see everything; users can only see their own"

In Bubble, this is two privacy rules: "When Current User's role is admin" → "View all fields, find in searches" + "When Current User is This Booking's User" → "View all fields, find in searches".

In Postgres, it's typically a single policy:

SELECT policy: allow if auth.uid() = user_id OR get_user_role(auth.uid()) = 'admin'

get_user_role() is a small SQL or PL/pgSQL function that returns the user's role. We define it once and use it across all policies. The function can be cached for performance.

Pattern 3: "Members of an organisation can see the org's records"

In Bubble, this involves a list field on the Organization Data Type and a privacy rule that checks list membership.

In Postgres, the cleanest pattern is a junction table (organization_members with user_id and organization_id) and a policy:

SELECT policy: allow if EXISTS (SELECT 1 FROM organization_members WHERE user_id = auth.uid() AND organization_id = bookings.organization_id)

This pattern needs an index on (user_id, organization_id) on the junction table or the policy gets slow.

Pattern 4: "Public records visible to everyone, private records visible to owners"

In Bubble, a "visibility" field controls whether the record is public. Privacy rules switch on its value.

In Postgres, the same pattern works:

SELECT policy: allow if visibility = 'public' OR auth.uid() = user_id

You can even skip the role check entirely for public records - the database doesn't care who's asking.

Pattern 5: "Conditional fields"

In Bubble, you can have a privacy rule that allows reading some fields but not others (e.g. "non-admins can see name but not salary").

In Postgres, RLS works at the row level, not the column level. For per-column access, you use column privileges alongside RLS:

REVOKE SELECT (salary) ON employees FROM authenticated; GRANT SELECT (salary) ON employees TO admin;

Or you use a database view that exposes only the appropriate columns and grant access to the view. View-based approaches scale better when there are many such restrictions.

The gotchas

Bubble rules that silently fail to apply

Bubble has a notorious behaviour where if a privacy rule's condition can't be evaluated (e.g. references a deleted user), the rule doesn't apply. The data may then be visible to anyone.

Postgres RLS doesn't have this failure mode - if the condition expression fails, the row is rejected. So migration will often surface fields that were "protected" in Bubble but readable in a particular edge case. The new app will be stricter, sometimes breaking workflows that depended on the leak.

This is the most common cause of "the new app shows different data than the old app" bugs at cutover.

"View all fields, but find in searches" mismatch

In Bubble you can configure "View all fields" without "Find in searches". This means a user can read the record if they have a direct link, but they can't list/search it.

Postgres SELECT policies don't distinguish. If the row is selectable, it's both findable and viewable. To get the same behaviour you need to constrain the search at the application layer instead.

The data API "everyone" rule

Bubble's data API has its own evaluation of privacy rules that's separate from in-app workflow evaluation. A rule that "Everyone else (default)" can see a Data Type means anyone with the public data API URL can read it.

When migrating, search for "Everyone else" rules in your Bubble app and confirm whether you really meant "public to the world" or "logged-in users only" - they're often confused. RLS policies make you state this explicitly.

Performance: RLS on every query

Every query to a table with RLS evaluates the policy. For complex policies (especially with subqueries) this can be slow on large tables.

Best practices:

  • Define a SQL function for the policy expression and mark it STABLE so Postgres can cache it within a transaction
  • Add covering indexes for the columns referenced in policies
  • Avoid policies that join large tables; precompute denormalised access fields if needed

The performance gap is usually invisible until you have millions of rows. But it's worth designing with it in mind from the start.

Service-role bypass

Supabase provides a "service role" key that bypasses RLS entirely. This is necessary for some legitimate operations (admin scripts, certain trusted server-side code). It's also the most common source of "we accidentally bypassed auth" bugs.

Rule: the service-role key is only used from server-side code that has its own auth check. Never expose it to the client. Never use it as a quick fix for an RLS policy that's getting in the way.

A worked example

Imagine you have a Bubble app with a Booking Data Type. Bubble privacy rules:

  1. Admins: View all fields, find in searches
  2. The host: View all fields, find in searches
  3. The guest: View all fields, find in searches
  4. Everyone else: No access

In Postgres + Supabase:

CREATE POLICY booking_access ON bookings FOR ALL TO authenticated USING (auth.uid() = host_id OR auth.uid() = guest_id OR get_user_role(auth.uid()) = 'admin');

That single policy covers all four rules. We use FOR ALL to apply it to SELECT, INSERT, UPDATE, and DELETE; if INSERT requires different logic, we split into separate policies.

The Supabase auth.uid() function returns the current authenticated user's ID. The get_user_role() function is a SQL helper we define once for the whole app.

What to do next

Privacy rule migration is one of the harder parts of any Bubble-to-code migration. If you'd like a senior engineer to walk through your specific privacy model, book a 30-minute discovery call.

If you want to evaluate the state of your current privacy rules first, our free Bubble app audit includes a Security section that flags common data-exposure issues.

Read next: Bubble to Next.js migration guide and How to audit your Bubble app before migration.

Got a Bubble or Canvas app you’d like a second pair of eyes on?

30-minute discovery call. We’ll look at your app live and tell you honestly what we’d do next.

Or grab the Bubble migration playbook PDF.