BuiltSafely

Home/Supabase

Supabase

Never let users edit the field your access rule trusts

If a rule decides access from a field on the user's own profile, like company, org_id or role, users must not be able to change it. How to spot it in Base44 and Supabase, and how to lock it down.

  • Verified: tested locally
  • Reviewed Oct 2026
  • 9 official sources
Sources Last checked October 20, 2026 · Badge: verified (Supabase/Postgres SQL tested on a throwaway local Postgres 17; the Base44 step is docs-only) · Sources: official Base44, Supabase and PostgreSQL docs (linked below) · Educational, not an audit

Lots of apps decide who sees what from a field on the user's own profile: a company, an org_id, a team_id or a role. That works only if the user can't change that field themselves. If they can, they can type in someone else's value and get that access. It's one of the oldest privilege-escalation mistakes there is, and AI-built apps make it easy to ship by accident, because the profile form and the access rule usually get built at different times.

The rule for every tool: any field that decides access must not be writable by the user it describes.

1. What it looks like

Here's a simple multi-company app:

  • Each profile has an org_id.
  • A rule says "you can read documents where documents.org_id matches your profile's org_id."
  • The "edit profile" screen lets users update their own profile row.

If that update permission covers every column, a user can set their own org_id to another company's value. The access rule still works exactly as written. It's just trusting a value the user picked. The same goes for a role column that a rule checks for admin.

Base44 describes the same risk on its own platform: if your rules rely on a user-editable field, "a user could change it to another organization's value and get that organization's access" (Base44: Running a security scan).

Any field that decides access must not be writable by the user it describes.
BuiltSafely Research

2. Base44: let the security scan find these fields

Docs-only: we didn't test the Base44 steps in this section on a live Base44 app. They come from Base44's official docs. The Supabase SQL further down was tested locally.

On October 19, 2026, Base44 added a scan check that "flags custom User fields that your data permission rules rely on but app users can change themselves" (Base44 changelog).

Why it applies to Base44 apps: you can add custom fields to the built-in User entity and use them in rules with {{user.data.*}}. Base44's own example limits records to the user's company (Base44: User schema):

{
  "rls": {
    "read": {"data.company": "{{user.data.company}}"},
    "update": {"data.company": "{{user.data.company}}"}
  }
}

And by default, "App users can change the custom fields on their own user profile" (Base44: Running a security scan).

What to do (steps from Base44: Running a security scan):

  1. Open your app editor, click Dashboard, then Security, then Run Security Scan. If you changed the app since the last scan, run it again before you apply any fixes.
  2. Look for the card User fields users can change. It lists each field and the data tables whose rules read it. Base44 rates these High if anyone can sign up for your app, and Medium otherwise.
  3. Expand an issue to see which rules read the field and what the fix will do.
  4. Click Fix All on that card. Base44 says the AI locks each field so only your app's editors can change it, and moves every place your app sets it (for example during onboarding) into a check that runs on the server.
  5. Test any signup or onboarding steps that set the field, then run the scan again.

Base44 records every fix as a message in your AI chat and creates a checkpoint first, so you can revert a fix that breaks something (Base44: Running a security scan). If you do revert, the field is user-editable again, so fix the flow that broke rather than leaving it reverted.

  • The scan shows no open issues under User fields users can change.
  • Signup and onboarding still set company, team or role correctly after the fix.
  • Signed in as a normal user, you can't change your own company, team or role from the profile screen.

3. Supabase: stop users updating their own role or org column

In Supabase, the same mistake usually lives in a profiles table with an update policy like "users can update their own row." That policy decides which rows a user can change. It doesn't decide which columns.

Two Postgres facts explain why the obvious policy isn't enough:

  • If you leave out with check, Postgres reuses using for it. "If only a USING clause is specified, then that clause will be used for both USING and WITH CHECK cases" (PostgreSQL: CREATE POLICY). So using (auth.uid() = id) only checks that the row is still yours after the update. Changing org_id or role passes.
  • A policy can't see the old row directly. The check expression "is evaluated against the proposed new contents of the row, not the original contents" (PostgreSQL: CREATE POLICY). There's no OLD.org_id inside a policy.

The examples below use this table:

create table public.profiles (
  id uuid primary key,           -- the auth user's id
  display_name text,
  org_id uuid references public.orgs,
  role text not null default 'member'
);

Supabase's guidance is to keep roles in a dedicated table rather than lean on column-level privileges. Its docs say: "We do not recommend using column-level privileges for most users. Instead, we recommend using RLS policies in combination with a dedicated table for handling user roles" (Supabase: Column Level Security). So start with the recommended fix below. Use the shorter alternatives only if you need to keep an existing profiles schema for now.

Recommended: keep membership and roles where users can't write them

The cleanest fix is to not store access data in a row the user can edit at all.

Option 1: a separate membership table that users can only read. Keep org_members (user_id, org_id, role) in a table where authenticated has select at most. Check it from your policies through a security definer helper in a schema the API doesn't expose. Supabase's RBAC example revokes all access to its roles table from client roles (Supabase: Custom Claims & RBAC), and its RLS docs describe the helper-function pattern (Supabase: Row Level Security).

create table public.org_members (
  user_id uuid references auth.users on delete cascade,
  org_id uuid references public.orgs,
  role text not null default 'member',
  primary key (user_id, org_id)
);
alter table public.org_members enable row level security;

-- Users may read their own memberships, never write them
revoke all on table public.org_members from anon, authenticated;
grant select on table public.org_members to authenticated;
create policy "read own memberships" on public.org_members
  for select to authenticated
  using ( user_id = (select auth.uid()) );

-- Private helper: the caller's org ids
create schema if not exists private;
create function private.my_org_ids() returns setof uuid
language sql stable security definer set search_path = '' as
$$ select org_id from public.org_members where user_id = (select auth.uid()) $$;
revoke execute on function private.my_org_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.my_org_ids() to authenticated;

-- Access rules check membership, not a profile column
create policy "read org documents" on public.documents
  for select to authenticated
  using ( org_id in (select private.my_org_ids()) );

Add and change memberships only from your server (with a secret key) or from the database. Then stop reading org_id and role from profiles in any policy. Our Supabase RLS guide walks through the same pattern for admin roles.

Option 2: app_metadata, not user_metadata. Supabase says raw_user_meta_data "can be updated by the authenticated user" and "is not a good place to store authorization data", while raw_app_meta_data "cannot be updated by the user" (Supabase: Row Level Security).

-- Org taken from app_metadata, which users can't edit
create policy "read own org reports" on public.reports
  for select to authenticated
  using ( org_id = ((select auth.jwt()) -> 'app_metadata' ->> 'org_id')::uuid );

A JWT isn't always up to date. A change to app_metadata only shows up in auth.jwt() after the user's token refreshes, so removing someone from an org isn't instant (Supabase: Row Level Security).

Shorter alternative for an existing schema: a BEFORE UPDATE trigger

If org_id and role have to stay on profiles for now, a trigger can block users from changing them. Unlike a policy, a trigger sees both versions of the row. In a PL/pgSQL row trigger, NEW holds the new row and OLD the old one (PostgreSQL: Trigger functions).

create schema if not exists private;

create function private.block_protected_profile_columns()
returns trigger language plpgsql as $$
begin
  if current_user in ('anon', 'authenticated')
     and (new.org_id is distinct from old.org_id
          or new.role is distinct from old.role) then
    raise exception 'org_id and role can only be changed by the server'
      using errcode = '42501';
  end if;
  return new;
end $$;

create trigger profiles_protect_columns
  before update on public.profiles
  for each row execute function private.block_protected_profile_columns();

Leave the function as the default security invoker, so current_user is the role making the request. Changes from your server (service_role) or the SQL editor still go through.

Shorter alternative for an existing schema: a WITH CHECK that calls a helper

A policy can't read OLD, but it can compare the new value with the value currently stored, read through a private security definer helper:

create function private.my_org_id() returns uuid
language sql stable security definer set search_path = '' as
$$ select org_id from public.profiles where id = (select auth.uid()) $$;
create function private.my_role() returns text
language sql stable security definer set search_path = '' as
$$ select role from public.profiles where id = (select auth.uid()) $$;
revoke execute on function private.my_org_id(), private.my_role() from public;
grant usage on schema private to authenticated;
grant execute on function private.my_org_id(), private.my_role() to authenticated;

create policy "update own profile, not org or role" on public.profiles
  for update to authenticated
  using ( (select auth.uid()) = id )
  with check (
    (select auth.uid()) = id
    and org_id is not distinct from (select private.my_org_id())
    and role   is not distinct from (select private.my_role())
  );

Any change to org_id or role then fails with "new row violates row-level security policy for table profiles" (see new row violates row-level security policy).

Gotcha: don't inline the subquery. Writing select p.org_id from public.profiles p ... directly inside a policy on profiles raised infinite recursion detected in policy for relation "profiles" in our test. The helper function avoids that; see 42P17 infinite recursion. Never put a security definer function in an exposed schema such as public (Supabase: Row Level Security).

Both alternatives cover updates only. If users can insert their own profile row, they can still insert one with any org_id and role they like. Create profile rows from the server, or limit the insert grant as in the note below.

Advanced: column-level grants

You can also grant update (and insert) only on the columns users may edit (Supabase: Column Level Security). Supabase calls this "an advanced feature", so treat it as an extra layer, not the main fix.

revoke update on table public.profiles from authenticated;
grant update (display_name) on table public.profiles to authenticated;
revoke insert on table public.profiles from authenticated;
grant insert (id, display_name) on table public.profiles to authenticated;

Gotcha: revoke the table-wide grant first. Revoking just the org_id and role columns while table-wide update is still granted changes nothing. PostgreSQL warns that "the table-level grant is unaffected by a column-level operation" (PostgreSQL: GRANT). After this change, any request that sends org_id or role fails with 42501 "permission denied for table profiles", so make your profile form send only editable fields (42501 permission denied).

  • Access rules read membership or roles from a table users can't write, or from app_metadata.
  • No policy reads user_metadata to decide access.
  • If org_id / role still live on profiles, a trigger or with check blocks users changing them, and users can't choose them on insert.
  • Role and membership changes happen only on the server or in the database.

4. Check your own app (any tool)

  1. List the fields your rules trust. For every access rule, write down which user-side value it compares against (company, org, team, role, plan, "is_admin").
  2. For each one, ask where users can write it. That means profile forms, onboarding, signup, updateMe-style SDK calls, and direct API calls with the user's own token.
  3. Try it on a test account. Sign in as a normal test user and try to change that field through the app and through the API. It should fail. Then confirm the user still sees only their own organization's data.

Only test apps you own.

How we tested the SQL on this page

On October 20, 2026 we ran every Supabase snippet above against a throwaway local PostgreSQL 17.11 database, with no real app or data involved. We created stand-ins for Supabase's anon / authenticated / service_role roles and its auth.uid() / auth.jwt() helpers, then acted as users with set local role and request.jwt.claim.sub.

  • The unsafe pattern: with an update policy using using only, a member changed their own org_id and role to another company's values and could then read that company's document. With a table-wide insert grant and an ownership-only insert policy, a brand-new user inserted a profile with another company's org_id and role: admin.
  • Recommended (membership table): the user couldn't insert or update rows in org_members ("permission denied for table org_members") and saw only their own organization's document. app_metadata: the policy ignored an org_id the user had placed in user_metadata.
  • Trigger: display-name updates passed; org_id / role changes raised the trigger's error. The same change made as service_role and as the database owner succeeded.
  • WITH CHECK helper: display-name updates passed; changing org_id or role (alone or alongside another column) failed with "new row violates row-level security policy for table profiles". The inline self-subquery version failed with 42P17 infinite recursion.
  • Column grants: after revoke update plus grant update (display_name), changing display_name worked and changing org_id or role failed with "permission denied for table profiles". The column-limited insert grant blocked inserting org_id / role. Revoking only the org_id / role columns while the table-wide grant stayed did not block the change, matching the PostgreSQL docs.

After restructuring this page we ran the exact SQL blocks shown above again on a fresh local database (same day) and got the same results, with the trigger and the with check policy each tested on their own.

The Base44 steps are from Base44's official docs. We didn't run them on a Base44 app. Your results can differ with other project settings, so test your own app.

Prompt you can give your AI builder

Find every access rule or RLS policy that compares against a value on the user's own
profile or user record (for example company, org_id, team_id, role or is_admin).
For each of those fields, make sure a signed-in user cannot change it themselves:
not from the profile form, not during signup or onboarding, and not through a direct
API call. Set those fields only from server-side code. Do not use user_metadata for
access decisions. Do not disable RLS or loosen any rule. List every field and every
change you propose, and wait for my OK before applying them.

Always review. An AI tool can get this wrong, so re-run your security scan or two-account test afterwards.

Supabase RLS guide · Base44 security guide · 42501 permission denied · new row violates row-level security policy · 42P17 infinite recursion

Your turn

Which field decides access in your app: company, team, role or something else? Tell us how you stop users editing it, and we'll add common patterns to this page.

This guide is educational and based on the vendor's public docs as of the review date. Platforms change: check the linked sources. Not a security audit. Disclaimer.

The BuiltSafely weekly

One email a week. What changed, and the one fix that matters.

Platform changes that affect the security of AI-built apps, checked against official docs. Free. Unsubscribe any time.