В MVP на базе как сервисе (Supabase и похожие платформы) браузер обращается к базе напрямую с публичным ключом, и это нормально: ключ виден любому посетителю и сам по себе ничего не защищает. Защищает Row Level Security — политики в PostgreSQL, которые для каждой строки решают, может ли текущий пользователь её прочитать или изменить. Таблица без RLS при доступном API открыта всем, у кого есть публичный ключ, а служебный ключ обходит RLS и должен жить только на сервере.
После материала вы сможете
- объяснить, почему публичный ключ в браузере — норма, а таблица без RLS при нём — открытые данные
- воспроизвести утечку на стенде PostgreSQL 17 + PostgREST: прочитать и изменить чужие записи с публичным ключом
- включить
ENABLE ROW LEVEL SECURITYи написать политикиUSINGиWITH CHECKдля чтения, вставки, изменения и удаления своих строк - проверить доступ запросами от имени двух пользователей и анонима и найти таблицы без RLS одним запросом
Что знать заранее
Проверено на
PostgreSQL 17 (17.11, postgres:17-alpine)PostgREST 16.4Node.js 24 LTS (24.18)curl 8.21
Сверено с документацией: 12 октября 2026 г.
Содержание
- Как утекают данные в MVP с базой как сервисом
- Стенд: PostgreSQL 17 и PostgREST в Docker
- Воспроизводим утечку: публичный ключ читает и меняет чужие записи
- Включаем RLS и пишем политики
- Проверка от имени двух пользователей и анонима
- Служебный ключ — только на сервере
- Ловушки: UPDATE без SELECT, представления, метаданные пользователя
- Практика
Как утекают данные в MVP с базой как сервисом
Платформы вроде Supabase строят API прямо из схемы базы: Supabase прямо пишет, что его REST API работает на PostgREST — тонком слое над PostgreSQL. Фронтенд обращается к этому API сам, без своего бэкенда, и отправляет в каждом запросе публичный ключ. Ключ лежит в JavaScript-бандле, его видно во вкладке «Сеть» браузера, и так задумано: документация Supabase прямо относит публичный (publishable, раньше — anon) ключ к коду, который уходит пользователям.
Что тогда защищает данные? Не секретность ключа, а роль, которую он даёт, и политики Row Level Security (RLS) в базе. Без входа запрос выполняется от роли anon, после входа — от роли authenticated, а идентификатор пользователя берётся из подписанного токена (JWT). Если на таблице нет RLS, а у роли есть права, любой посетитель с публичным ключом читает и меняет все строки.
- 01Браузер с публичным ключом
- 02Data API (PostgREST)
- 03Роль anon или authenticated
- 04Политики RLS
- 05Таблица
Как таблица остаётся без RLS? Чаще всего буднично. Агент или разработчик создаёт её SQL-запросом: по документации Supabase у таблиц, созданных в панели управления, RLS включён по умолчанию, а у созданных в SQL-редакторе или другими инструментами его нужно включить явно. При этом таблицы в схеме public по умолчанию получают права SELECT, INSERT, UPDATE и DELETE для ролей anon и authenticated (Supabase объявил переход к явной выдаче прав, но на момент проверки описывает именно такое поведение). Это частый пропуск при быстрой сборке, а не признак плохого кода: агент сделал ровно то, что просили, — таблицу.
Стенд: PostgreSQL 17 и PostgREST в Docker
Чтобы не трогать настоящий проект, соберём локальный стенд с тем же устройством: PostgreSQL 17 и PostgREST — тот же движок Data API. Роли названы как в Supabase, но это упрощённая эмуляция, а не сама платформа: схема auth, выдача ключей и вход пользователей там устроены сложнее.
-- Local stand that mimics a "database as a service" project (Supabase-style roles).
-- Not Supabase itself: a simplified emulation for the lesson.
-- API roles: the client key maps to anon / authenticated, the server key to service_role
create role anon nologin;
create role authenticated nologin;
create role service_role nologin bypassrls;
-- PostgREST connects as authenticator and switches to the role from the JWT
create role authenticator login password 'local-test-only' noinherit;
grant anon, authenticated, service_role to authenticator;
grant usage on schema public to anon, authenticated, service_role;
-- Default grants: every new table in public is reachable by the API roles
alter default privileges in schema public
grant select, insert, update, delete on tables to anon, authenticated, service_role;
-- auth.uid(): the user id from the JWT claims of the current request
create schema auth;
grant usage on schema auth to anon, authenticated, service_role;
create function auth.uid() returns uuid
language sql stable
as $$
select nullif(current_setting('request.jwt.claims', true)::jsonb ->> 'sub', '')::uuid
$$;PostgREST подключается к базе ролью authenticator, проверяет подпись JWT и для каждого запроса выполняет SET LOCAL ROLE с ролью из токена; без токена — роль из настройки db-anon-role. Claims токена доступны в SQL через current_setting('request.jwt.claims', true) — на этом построена auth.uid(). Таблица — как её создал бы агент по задаче «сделай записи клиентов на услуги»:
-- The table as it was created from the SQL editor: no RLS
create table public.bookings (
id bigint generated always as identity primary key,
user_id uuid not null default auth.uid(),
client_name text not null,
phone text not null,
service text not null,
starts_at timestamptz not null
);
-- Demo data: fictitious users and placeholder phone numbers
insert into public.bookings (user_id, client_name, phone, service, starts_at) values
('11111111-1111-4111-8111-111111111111', 'Alice', '+7 700 000 0001', 'haircut', '2026-10-20 10:00+05'),
('11111111-1111-4111-8111-111111111111', 'Alice', '+7 700 000 0001', 'manicure', '2026-10-21 12:00+05'),
('22222222-2222-4222-8222-222222222222', 'Bob', '+7 700 000 0002', 'massage', '2026-10-20 15:00+05');Токены для стенда подписываем сами тем же секретом, что знает PostgREST. Токен anon здесь играет роль публичного ключа из бандла, service — служебного ключа.
// Signs a test JWT (HS256) for the local stand: node make-jwt.mjs <anon|alice|bob|service>
// The secret is a local placeholder; real projects never keep it in code.
import { createHmac } from "node:crypto";
const SECRET = process.env.JWT_SECRET ?? "local-test-secret-not-for-production-0123456789";
const USERS = {
anon: { role: "anon" },
alice: { role: "authenticated", sub: "11111111-1111-4111-8111-111111111111" },
bob: { role: "authenticated", sub: "22222222-2222-4222-8222-222222222222" },
service: { role: "service_role" },
};
const claims = USERS[process.argv[2]];
if (!claims) throw new Error("usage: node make-jwt.mjs anon|alice|bob|service");
const b64url = (data) => Buffer.from(JSON.stringify(data)).toString("base64url");
const body = `${b64url({ alg: "HS256", typ: "JWT" })}.${b64url({ ...claims, exp: 2000000000 })}`;
const signature = createHmac("sha256", SECRET).update(body).digest("base64url");
console.log(`${body}.${signature}`);NET=kb-g-rls-net PG=kb-g-rls-pg API=kb-g-rls-api
JWT_SECRET="local-test-secret-not-for-production-0123456789"
docker network create $NET >/dev/null
docker run -d --rm --name $PG --network $NET -e POSTGRES_PASSWORD=test postgres:17-alpine >/dev/null
until docker exec $PG pg_isready -U postgres -q 2>/dev/null; do sleep 1; done; sleep 2
psql() { docker exec -i $PG psql -U postgres -X -q -v ON_ERROR_STOP=1 "$@"; }
psql -f - < sql/01-platform.sql && psql -f - < sql/02-app.sql
docker run -d --rm --name $API --network $NET -p 3901:3000 \
-e PGRST_DB_URI="postgres://authenticator:local-test-only@$PG:5432/postgres" \
-e PGRST_DB_SCHEMAS=public -e PGRST_DB_ANON_ROLE=anon -e PGRST_JWT_SECRET="$JWT_SECRET" \
postgrest/postgrest:v16.4 >/dev/nullВоспроизводим утечку: публичный ключ читает и меняет чужие записи
Берём публичный ключ — тот, что любой посетитель найдёт в бандле, — и просим у API все записи. Затем меняем чужую запись.
$ API=http://localhost:3901
$ ANON_KEY=$(node make-jwt.mjs anon)
$ curl -s "$API/bookings?select=id,client_name,phone,service" -H "Authorization: Bearer $ANON_KEY"; echo
[{"id":1,"client_name":"Alice","phone":"+7 700 000 0001","service":"haircut"},
{"id":2,"client_name":"Alice","phone":"+7 700 000 0001","service":"manicure"},
{"id":3,"client_name":"Bob","phone":"+7 700 000 0002","service":"massage"}]
$ curl -s -X PATCH "$API/bookings?id=eq.3" -H "Authorization: Bearer $ANON_KEY" -H "Content-Type: application/json" -H "Prefer: return=representation" -d '{"service":"cancelled"}'; echo
[{"id":3,"user_id":"22222222-2222-4222-8222-222222222222","client_name":"Bob","phone":"+7 700 000 0002","service":"cancelled","starts_at":"2026-10-20T10:00:00+00:00"}]Аноним получил имена и телефоны всех клиентов и отменил запись Боба. Входить в приложение не понадобилось: хватило публичного ключа и адреса API, а их знает браузер каждого посетителя. Такой пропуск легко найти и у себя — одним запросом к системному каталогу:
-- Tables in the public schema without RLS
select c.relname as table_without_rls
from pg_class c
where c.relnamespace = 'public'::regnamespace
and c.relkind in ('r', 'p')
and not c.relrowsecurity;$ docker exec -i kb-g-rls-pg psql -U postgres -X -q -f - < sql/05-no-rls.sql
table_without_rls
-------------------
bookings
(1 row)Включаем RLS и пишем политики
Правило для записей простое: пользователь видит и меняет только свои строки — те, где user_id совпадает с его идентификатором из токена.
-- 1. Turn on RLS: from now on, no policy = no rows
alter table public.bookings enable row level security;
-- 2. One policy per operation, always with an explicit role
create policy "bookings_select_own" on public.bookings
for select to authenticated
using ((select auth.uid()) = user_id);
create policy "bookings_insert_own" on public.bookings
for insert to authenticated
with check ((select auth.uid()) = user_id);
create policy "bookings_update_own" on public.bookings
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "bookings_delete_own" on public.bookings
for delete to authenticated
using ((select auth.uid()) = user_id);SELECT
- Условие
USING- Что делает база
- Отбрасывает строки, для которых условие ложно; ошибки нет
INSERT
- Условие
WITH CHECK- Что делает база
- Проверяет новую строку; если условие ложно — ошибка, вся команда отменяется
UPDATE
- Условие
USINGиWITH CHECK- Что делает база
USINGвыбирает, какие строки можно менять,WITH CHECK— какой строка может стать после изменения
DELETE
- Условие
USING- Что делает база
- Удаляет только строки, прошедшие условие; чужие молча пропускает
- После
ENABLE ROW LEVEL SECURITYдействует запрет по умолчанию: по документации PostgreSQL, если политики нет, не видно и нельзя изменить ни одной строки. - У каждой политики указана роль (
to authenticated). Безtoполитика по умолчанию применяется кPUBLIC, то есть и к анониму; Supabase советует всегда называть роль явно. (select auth.uid())вместоauth.uid()— совет из документации Supabase: обёртка вselectпозволяет планировщику вычислить значение один раз на запрос, а не на каждую строку.- Несколько разрешающих политик на одну операцию объединяются через
OR: лишняя широкая политика открывает всё, что закрыли остальные.
Проверка от имени двух пользователей и анонима
Политики, которые не проверены запросами, — это намерение, а не защита. Повторяем запросы через API от имени анонима, Алисы и Боба.
$ API=http://localhost:3901
$ ANON_KEY=$(node make-jwt.mjs anon); ALICE_KEY=$(node make-jwt.mjs alice); BOB_KEY=$(node make-jwt.mjs bob)
$ curl -s "$API/bookings?select=id,client_name" -H "Authorization: Bearer $ANON_KEY"; echo
[]
$ curl -s "$API/bookings?select=id,client_name" -H "Authorization: Bearer $ALICE_KEY"; echo
[{"id":1,"client_name":"Alice"},
{"id":2,"client_name":"Alice"}]
$ curl -s "$API/bookings?select=id,client_name" -H "Authorization: Bearer $BOB_KEY"; echo
[{"id":3,"client_name":"Bob"}]
$ curl -s -X PATCH "$API/bookings?id=eq.3" -H "Authorization: Bearer $ALICE_KEY" -H "Content-Type: application/json" -H "Prefer: return=representation" -d '{"service":"cancelled"}'; echo
[]
$ curl -s -w ' %{http_code}\n' -X POST "$API/bookings" -H "Authorization: Bearer $ALICE_KEY" -H "Content-Type: application/json" -d '{"user_id":"22222222-2222-4222-8222-222222222222","client_name":"Bob","phone":"+7 700 000 0002","service":"fake","starts_at":"2026-10-22T10:00:00+05:00"}'
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"bookings\""} 403
$ curl -s -w ' %{http_code}\n' -X POST "$API/bookings" -H "Authorization: Bearer $ALICE_KEY" -H "Content-Type: application/json" -H "Prefer: return=representation" -d '{"client_name":"Alice","phone":"+7 700 000 0001","service":"pedicure","starts_at":"2026-10-22T10:00:00+05:00"}'
[{"id":5,"user_id":"11111111-1111-4111-8111-111111111111","client_name":"Alice","phone":"+7 700 000 0001","service":"pedicure","starts_at":"2026-10-22T05:00:00+00:00"}] 201Аноним получает пустой список
RLS включён, политик для
anonнет — действует запрет по умолчанию. Ответ[], а не ошибка: база просто не показывает строки.Алиса и Боб видят только свои записи
Один и тот же запрос возвращает разные строки: условие
USINGподставляет идентификатор из токена.Чужая запись не меняется
PATCHзаписи Боба от имени Алисы вернул[]: строка не прошлаUSINGи молча не попала под изменение.Подделать владельца нельзя
Вставка с чужим
user_idполучила ошибку42501и HTTP 403 — сработалWITH CHECK. Безuser_idв запросе подставилось значение по умолчаниюauth.uid(), и запись создана.
Те же проверки удобно повторять прямо в базе — например, в тестах миграций. Делаем то, что делает PostgREST на каждый запрос: переключаем роль и кладём claims в настройки транзакции.
$ docker exec -i kb-g-rls-pg psql -U postgres -X -a -f - < sql/10-as-users.sql
-- What PostgREST does for every request: switch role and put JWT claims into the transaction
\set alice '{"role":"authenticated","sub":"11111111-1111-4111-8111-111111111111"}'
\set bob '{"role":"authenticated","sub":"22222222-2222-4222-8222-222222222222"}'
-- Bob: sees only his own booking
begin;
BEGIN
set local role authenticated;
SET
select set_config('request.jwt.claims', :'bob', true) is not null as claims_set;
claims_set
------------
t
(1 row)
select auth.uid() as uid, current_user;
uid | current_user
--------------------------------------+---------------
22222222-2222-4222-8222-222222222222 | authenticated
(1 row)
select id, client_name, service from public.bookings order by id;
id | client_name | service
----+-------------+---------
3 | Bob | massage
(1 row)
rollback;
ROLLBACK
-- Anonymous visitor: RLS is on, no policy for anon, so no rows
begin;
BEGIN
set local role anon;
SET
select count(*) as visible_to_anon from public.bookings;
visible_to_anon
-----------------
0
(1 row)
rollback;
ROLLBACK
-- Alice: someone else's row is silently skipped, someone else's user_id is an error
begin;
BEGIN
set local role authenticated;
SET
select set_config('request.jwt.claims', :'alice', true) is not null as claims_set;
claims_set
------------
t
(1 row)
select id, client_name, service from public.bookings order by id;
id | client_name | service
----+-------------+----------
1 | Alice | haircut
2 | Alice | manicure
5 | Alice | pedicure
(3 rows)
update public.bookings set service = 'cancelled' where id = 3;
UPDATE 0
insert into public.bookings (user_id, client_name, phone, service, starts_at)
values ('22222222-2222-4222-8222-222222222222', 'Bob', '+7 700 000 0002', 'fake', now());
psql:<stdin>:26: ERROR: new row violates row-level security policy for table "bookings"
rollback;
ROLLBACKСлужебный ключ — только на сервере
У служебной роли атрибут BYPASSRLS: по документации PostgreSQL такие роли и суперпользователи всегда обходят политики. Тот же запрос со служебным ключом возвращает всё:
$ API=http://localhost:3901
$ SERVICE_KEY=$(node make-jwt.mjs service)
$ curl -s "$API/bookings?select=id,client_name" -H "Authorization: Bearer $SERVICE_KEY"; echo
[{"id":1,"client_name":"Alice"},
{"id":2,"client_name":"Alice"},
{"id":3,"client_name":"Bob"},
{"id":5,"client_name":"Alice"}]Supabase формулирует правило однозначно: секретный ключ нельзя использовать в браузере и показывать пользователям, он для серверного кода — сервера, функций, фоновых задач. Если этот ключ попал в бандл, RLS больше ничего не защищает: ключ нужно отозвать и выпустить новый.
- Служебный ключ читается только в серверном коде и не имеет публичного префикса вроде
NEXT_PUBLIC_илиVITE_ - В клиентских компонентах, мобильном приложении и логах нет служебного ключа и строки подключения к базе
- Серверный код, который работает со служебным ключом, сам проверяет права пользователя — RLS за него этого не сделает
- Ключ, который хоть раз попал в репозиторий или бандл, отозван и заменён
Как хранить ключи и что делать при утечке, подробно разобрано в уроке «Секреты и переменные окружения».
Ловушки: UPDATE без SELECT, представления, метаданные пользователя
Две ловушки проверим на стенде; изменения откатываются в конце каждой транзакции.
$ docker exec -i kb-g-rls-pg psql -U postgres -X -a -f - < sql/20-traps.sql
\set alice '{"role":"authenticated","sub":"11111111-1111-4111-8111-111111111111"}'
-- Trap 1: an UPDATE policy without a SELECT policy updates nothing
begin;
BEGIN
drop policy "bookings_select_own" on public.bookings;
DROP POLICY
set local role authenticated;
SET
select set_config('request.jwt.claims', :'alice', true) is not null as claims_set;
claims_set
------------
t
(1 row)
update public.bookings set service = 'pedicure' where id = 1;
UPDATE 0
rollback;
ROLLBACK
-- Trap 2: a view created by the owner ignores the table's RLS
begin;
BEGIN
create view public.bookings_calendar as select starts_at, client_name, phone from public.bookings;
CREATE VIEW
set local role anon;
SET
select count(*) as rows_via_view from public.bookings_calendar;
rows_via_view
---------------
4
(1 row)
reset role;
RESET
alter view public.bookings_calendar set (security_invoker = true);
ALTER VIEW
set local role anon;
SET
select count(*) as rows_via_invoker_view from public.bookings_calendar;
rows_via_invoker_view
-----------------------
0
(1 row)
rollback;
ROLLBACK- UPDATE без политики SELECT ничего не меняет: команде нужно прочитать строки по условию
WHERE, а прочитать их нельзя. Документация PostgreSQL требует для такогоUPDATEи права, и политики наSELECT; Supabase предупреждает, что без политики SELECT обновление «не работает как ожидается». - Представление (view), созданное владельцем таблицы, по умолчанию проверяет права от имени владельца, и RLS не срабатывает: аноним увидел все четыре записи. С
security_invoker = true(PostgreSQL 15 и новее) представление подчиняется политикам того, кто делает запрос. Права по умолчанию касаются и представлений, поэтому новое представление сразу доступно через API. - Метаданные пользователя, которые он может менять сам (в Supabase —
raw_user_meta_data, обновляется черезsupabase.auth.update()), нельзя использовать в политиках как признак роли: пользователь назначит себе любую. - Владелец таблицы и роли с
BYPASSRLSобходят политики, если не включёнFORCE ROW LEVEL SECURITY. Подробно эти случаи и изоляция арендаторов разобраны в уроке о multi-tenant SaaS.
Второй слой защиты описан в документации Supabase: отозвать у ролей API права по умолчанию, чтобы новая таблица или функция не становилась доступной через API, пока права не выданы явно. RLS при этом остаётся обязательным на каждой таблице и представлении, к которым API обращается.
Практика
Как проверено6 · пройденосверено 12 октября 2026 г.
- запущено, работает
Стенд: sql/01-platform.sql, sql/02-app.sql, make-jwt.mjs, run-lab.sh (PostgreSQL 17 + PostgREST, роли anon/authenticated/service_role, auth.uid() из request.jwt.claims)
bash run-lab.sh в Git Bash: docker run --rm postgres:17-alpine (PostgreSQL 17.11) и postgrest/postgrest:v16.4 в сети kb-g-rls-net, имена kb-g-rls-pg / kb-g-rls-api; контейнеры и сеть удаляются после прогона; токены подписаны node:crypto (Node.js 24.18), запросы — curl 8.21; вывод в out/*.txt, лог PostgREST — out/postgrest.log
- запущено, работает
Утечка без RLS: чтение всех записей и PATCH чужой записи с anon-токеном; поиск таблиц без RLS (sql/05-no-rls.sql)
out/1-leak.txt, out/2-find.txt (до RLS — bookings), out/2-find-after.txt (после — 0 строк)
- запущено, работает
Политики sql/03-rls.sql и проверка через API от имени anon, Alice, Bob; service_role
out/3-after.txt: anon — [], Alice — 2 строки, Bob — 1, PATCH чужой — [], POST с чужим user_id — 42501/403, своя вставка — 201; out/5-service.txt: service_role видит все строки
- запущено, работает
Проверка в psql от имени пользователей (sql/10-as-users.sql): set local role + set_config('request.jwt.claims', …, true)
out/4-psql.txt: Bob — 1 строка, anon — 0, Alice — UPDATE 0 для чужой строки и ERROR new row violates row-level security policy при вставке с чужим user_id
- запущено, работает
Ловушки (sql/20-traps.sql): UPDATE без SELECT-политики, представление без security_invoker
out/6-traps.txt: UPDATE 0 без SELECT-политики; anon через представление видит 4 строки, с security_invoker = true — 0; всё в транзакциях с ROLLBACK
- запущено, работает
Упражнения: DELETE чужой записи (7-ex-repeat), таблица reviews (sql/30-reviews.sql + 31-reviews-check.sql)
out/7-ex-repeat.txt: DELETE от Bob — [], запись Alice на месте; out/8-ex-modify.txt: anon читает отзыв, UPDATE/DELETE от Bob — 0, INSERT от anon — ошибка политики
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.