Supabase's Oct 30 change: new tables need a GRANT
From October 30, 2026, new public tables in existing Supabase projects aren't reachable through the Data API until you grant access. What changes, what the error looks like, and the safe fix.
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.

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.
Here's a simple multi-company app:
org_id.documents.org_id matches your profile's org_id."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.
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):
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.
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:
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.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.
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).
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.
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.
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).
app_metadata.user_metadata to decide access.org_id / role still live on profiles, a trigger or with check blocks users changing them, and users can't choose them on insert.updateMe-style SDK calls, and direct API calls with the user's own token.Only test apps you own.
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.
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.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.org_id / role changes raised the trigger's error. The same change made as service_role and as the database owner succeeded.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.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.
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
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.
From BuiltSafely · builtsafely.com · https://builtsafely.com/guides/editable-access-field/
The BuiltSafely weekly
Platform changes that affect the security of AI-built apps, checked against official docs. Free. Unsubscribe any time.