RLS в Supabase: как проверить, что таблицы не открыты всем

27 сентября 2026 · 9 мин чтения · Безопасность, пункты 02, 12

Row Level Security (RLS) — механизм Postgres, который определяет, какие строки таблицы может прочитать или изменить конкретный запрос. Supabase открывает таблицы схемы public через автоматический REST API, а публичный ключ проекта лежит в коде сайта. Если на таблице выключен RLS или политика разрешает всё (using (true)), любой человек с адресом проекта и этим ключом может читать данные, а при выключенном RLS и менять их. Проверить свой проект можно минут за 15: Security Advisor в панели Supabase, несколько SQL-запросов к pg_class и pg_policies и запрос curl к тестовой копии.

Почему таблица Supabase без RLS открыта всем

Для таблиц в открытых схемах (по умолчанию это public) Supabase автоматически создаёт REST API, в документации он называется Data API. Каждый запрос к нему выполняется от роли anon, если пользователь не вошёл, или authenticated, если вошёл.

Postgres проверяет запрос в два этапа (документация Supabase):

  • гранты (права на таблицу) определяют, может ли роль вообще читать, добавлять, менять или удалять строки;
  • политики RLS определяют, к каким именно строкам это применяется.

Публичный ключ (publishable или старый anon) по замыслу лежит в браузере, его видит любой посетитель. На существующих проектах Supabase по умолчанию выдаёт ролям anon и authenticated права на чтение, добавление, изменение и удаление в новых таблицах public. Если RLS выключен, остаются только эти права, и таблица открыта на чтение и запись. Если RLS включён, но политик нет, API не отдаёт ни одной строки.

В 2026 году Supabase меняет правила выдачи прав (объявление):

  • с 30 мая 2026 года в новых проектах права на новые таблицы по умолчанию не выдаются;
  • с 30 октября 2026 года то же действует для существующих проектов, но только для новых таблиц. Старые таблицы сохраняют свои права.

RLS по-прежнему нужен. Если проект создан до 30 мая 2026 года и эту настройку при создании не меняли, права на таблицы уже выданы.

Что бывает без RLS, показывает CVE-2025-48757: в 170 из 1645 проверенных проектов на Lovable данные были доступны через публичный ключ. Подробнее об этом случае и о других пунктах самопроверки — в статье Как проверить безопасность проекта из Lovable, Bolt или Cursor.

Как проверить RLS в панели Supabase

Откройте Advisors → Security Advisor (документация). Он проверяет схему базы и выдаёт список предупреждений. Для RLS важны эти:

Проверка Что означает
0013_rls_disabled_in_public Таблица в public без RLS, доступна любому с адресом проекта
0007_policy_exists_rls_disabled Политики написаны, но RLS выключен, и они не действуют
0024_permissive_rls_policy Политика с using (true) или with check (true)
0010_security_definer_view Представление обходит RLS исходных таблиц
0028, 0029 Функцию security definer может вызвать гость или любой вошедший пользователь
0015_rls_references_user_metadata Политика опирается на данные, которые пользователь меняет сам
0008_rls_enabled_no_policy RLS включён, политик нет: данные закрыты, проверьте, что приложение работает
0025_public_bucket_allows_listing Содержимое публичного бакета можно перечислить

Supabase советует сверять каждое предупреждение со своей моделью доступа. Таблица с новостями для всех может обоснованно иметь using (true) на чтение.

Если проект работает на Lovable Cloud, доступа к панели Supabase нет (объяснение Supabase). Тогда используйте раздел Security в Lovable.

SQL-запрос: какие таблицы без RLS

Выполните в SQL Editor:

select n.nspname as schema_name,
       c.relname as table_name,
       c.relrowsecurity as rls_enabled
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.relkind in ('r', 'p')
  and n.nspname = 'public'
order by c.relrowsecurity, c.relname;

relkind in ('r', 'p') отбирает обычные и секционированные таблицы. Строки с rls_enabled = false окажутся вверху списка, их нужно закрыть первыми. Если вы добавили в Data API другие схемы, допишите их в условие n.nspname.

Включается RLS одной командой:

alter table public.orders enable row level security;

После этого запросы с публичным ключом не получают из таблицы ни одной строки, пока вы не напишете политики.

SQL-запрос: какие политики разрешают всё

Список всех политик:

select tablename, policyname, permissive, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;

Как читать результат:

  • roles — к каким ролям применяется политика. {public} означает все роли, включая anon. Так бывает, если в политике не указан to.
  • cmd — операция: SELECT, INSERT, UPDATE, DELETE или ALL.
  • qual — условие using, оно отбирает существующие строки.
  • with_check — условие with check, оно проверяет новые и изменённые строки.

Политики, которые пропускают любые строки:

select tablename, policyname, roles, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
  and (qual = 'true' or with_check = 'true');

Права ролей на таблицы:

select table_name,
       grantee,
       string_agg(privilege_type, ', ' order by privilege_type) as privileges
from information_schema.role_table_grants
where table_schema = 'public'
  and grantee in ('anon', 'authenticated')
group by table_name, grantee
order by table_name, grantee;

Если гостям таблица не нужна, заберите у anon права целиком: revoke all on table public.orders from anon;.

Как проверить таблицы через curl на своём проекте

Проверяйте только свой проект. Для запросов на запись используйте тестовую копию с тестовыми данными. Адрес проекта и publishable-ключ лежат в Settings → API Keys. Формат запроса взят из документации Supabase.

Запрос без входа, как от гостя:

curl "https://<PROJECT_REF>.supabase.co/rest/v1/orders?select=*&limit=5" \
  -H "apikey: <PUBLISHABLE_KEY>"

Для закрытой таблицы правильный ответ: пустой массив [] или ошибка с кодом 42501. Если пришли строки, таблица открыта.

Затем проверьте запрос от пользователя. Получите токен тестового аккаунта:

curl -X POST "https://<PROJECT_REF>.supabase.co/auth/v1/token?grant_type=password" \
  -H "apikey: <PUBLISHABLE_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"email": "test-a@example.com", "password": "<ПАРОЛЬ_ТЕСТОВОГО_АККАУНТА>"}'

Из ответа возьмите access_token и запросите чужие строки:

curl "https://<PROJECT_REF>.supabase.co/rest/v1/orders?select=*&user_id=eq.<ID_ДРУГОГО_ПОЛЬЗОВАТЕЛЯ>" \
  -H "apikey: <PUBLISHABLE_KEY>" \
  -H "Authorization: Bearer <ACCESS_TOKEN>"

Правильный ответ: []. На тестовой копии повторите то же для записи: POST с чужим user_id должен вернуть ошибку new row violates row-level security policy.

Функции из схемы public тоже доступны через API по адресу /rest/v1/rpc/<имя_функции>. На тестовой копии вызовите каждую без токена пользователя и посмотрите, что она возвращает.

Для постоянной проверки в документации Supabase есть примеры тестов политик на pgTAP. Файл теста создаётся командой supabase test new, запускаются тесты командой supabase test db.

Как написать политику «пользователь видит только свои строки»

Шаблон из документации Supabase, по одной политике на операцию:

alter table public.notes enable row level security;

create policy "notes_select_own"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "notes_insert_own"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "notes_update_own"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "notes_delete_own"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );

create index notes_user_id_idx on public.notes using btree (user_id);

Что здесь важно:

  • auth.uid() возвращает id вошедшего пользователя. Для гостя он возвращает null, и условие не выполняется.
  • to authenticated не даёт политике срабатывать для гостей.
  • (select auth.uid()) в скобках Supabase рекомендует для скорости: так функция вычисляется один раз на весь запрос.
  • Индекс по user_id нужен, чтобы политика не замедляла запросы на больших таблицах. Подробнее о медленных запросах — в статье почему вайбкод-проект тормозит при росте.

Как настроить RLS по организации в B2B

В B2B-сервисе строки принадлежат организации, а доступ зависит от членства. Политика на memberships, которая сама читает memberships, падает с ошибкой infinite recursion detected in policy. В документации Supabase для этого предложена функция security definer в закрытой схеме:

create table public.memberships (
  org_id uuid not null references public.organizations (id) on delete cascade,
  user_id uuid not null references auth.users (id) on delete cascade,
  role text not null default 'member',
  primary key (org_id, user_id)
);

create index memberships_user_id_idx on public.memberships using btree (user_id);

create schema if not exists private;

create function private.user_org_ids()
returns setof uuid
language sql
security definer
set search_path = ''
stable
as $$
  select org_id from public.memberships
  where user_id = (select auth.uid())
$$;

revoke execute on function private.user_org_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.user_org_ids() to authenticated;

alter table public.organizations enable row level security;

create policy "organizations_select_members"
on public.organizations for select
to authenticated
using ( id in (select private.user_org_ids()) );

alter table public.memberships enable row level security;

create policy "memberships_select_members"
on public.memberships for select
to authenticated
using ( org_id in (select private.user_org_ids()) );

alter table public.projects enable row level security;

create policy "projects_select_members"
on public.projects for select
to authenticated
using ( org_id in (select private.user_org_ids()) );

create policy "projects_insert_members"
on public.projects for insert
to authenticated
with check ( org_id in (select private.user_org_ids()) );

create policy "projects_update_members"
on public.projects for update
to authenticated
using ( org_id in (select private.user_org_ids()) )
with check ( org_id in (select private.user_org_ids()) );

Схема private не должна быть в списке Exposed schemas настроек Data API. Иначе функцию можно вызвать через API с правами её владельца.

На memberships нет политики на добавление строк, и это намеренно. Политика with check (user_id = auth.uid()) позволила бы пользователю добавить себя в любую организацию. Приглашения обрабатывайте в серверной функции, которая проверяет роль приглашающего.

Частые ошибки в политиках RLS

Политика using (true)

create policy "Enable read access for all users"
on public.orders for select
using (true);

У такой политики нет to, поэтому она действует и для гостей. Разрешающие политики Postgres объединяет через ИЛИ (документация Postgres). Одна политика using (true) на чтение открывает таблицу для чтения, сколько бы других разрешающих политик на ней ни было.

Вариант to authenticated using (true) пускает любого, кто зарегистрировался. Если в проекте включён анонимный вход, такие пользователи тоже получают роль authenticated (документация). Политика using (true) уместна только для данных, которые действительно публичны. Исправление: удалить политику и написать условие по владельцу или организации.

drop policy "Enable read access for all users" on public.orders;

Нет with check или он равен true

  • У политики на INSERT есть только with check. with check (true) позволяет создавать строки с чужим user_id или org_id.
  • Если у политики на UPDATE нет with check, Postgres применяет условие using и к новой версии строки. Явный with check (true) эту защиту снимает: пользователь может переписать свою строку на чужую организацию. Пишите оба условия, как в шаблоне выше.
  • RLS работает на уровне строк. Если пользователь может менять свой профиль, он может поменять в нём и колонку role. Supabase рекомендует хранить роли в отдельной таблице, которую пользователь не может менять. Другой вариант: права на отдельные колонки (документация):
revoke update on table public.profiles from authenticated;
grant update (full_name, avatar_url) on table public.profiles to authenticated;

Политика на user_metadata

Поле raw_user_meta_data пользователь меняет сам через клиентскую библиотеку. Supabase прямо пишет, что для прав доступа оно не подходит. Роли храните в raw_app_meta_data или в отдельной таблице.

Представления (views)

Представления обычно создаются от пользователя postgres и по умолчанию обходят RLS исходных таблиц. В Postgres 15 и новее это исправляет параметр security_invoker. Найдите представления в public:

select c.relname as view_name,
       c.reloptions
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'v';

Если в reloptions нет security_invoker=true или security_invoker=on, исправьте:

alter view public.orders_summary set (security_invoker = true);

Материализованные представления хранят готовую копию данных, политики RLS исходных таблиц на них не действуют. Security Advisor отмечает их в API отдельной проверкой 0016_materialized_view_in_api.

Функции security definer

Такая функция выполняется с правами создателя. Если её создал postgres, она обходит RLS. Функцию из public можно вызвать через /rest/v1/rpc/, если у роли есть право на её выполнение. Список таких функций:

select p.proname as function_name,
       pg_get_function_identity_arguments(p.oid) as arguments,
       has_function_privilege('anon', p.oid, 'execute') as anon_can_execute
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
  and p.prosecdef;

Варианты исправления из документации Security Advisor:

  • перенести функцию в закрытую схему, как private.user_org_ids() выше;
  • забрать право вызова: revoke execute on function public.my_function(uuid) from anon, public; (подставьте свои имя и аргументы, а если функция не нужна и вошедшим пользователям, добавьте authenticated);
  • сделать функцию security invoker, чтобы на неё действовал RLS.

В каждой функции security definer задавайте set search_path = '' и пишите имена таблиц со схемой.

Секретный ключ в браузере

Ключ secret или старый service_role обходит все политики. Если он попал в код сайта, RLS не защищает ничего. Как найти ключи в сборке, описано в чек-листе безопасности вайбкод-проекта.

Как не открыть таблицы снова

  • Включайте RLS и пишите политики в той же миграции, где создаёте таблицу.
  • После каждой миграции, которую сгенерировал ИИ-ассистент, запускайте Security Advisor и запросы из этой статьи.
  • Держите тесты pgTAP с двумя пользователями и запускайте их перед выкладкой.

Если хотите, чтобы проект проверил инженер, оставьте заявку на Кодосмотр.