Supabase RLS: що це і як писати політики доступу
RLS це механізм Postgres, а не винахід Supabase. Розбираємо на чистому Postgres: як увімкнути політики, перевірити їх від імені звичайної ролі та хто їх обходить.
Що таке Supabase RLS і чому без нього ніяк
Supabase RLS це звичайний row level security (захист на рівні рядків) із PostgreSQL, а не окрема технологія Supabase. Postgres уміє це ще з версії 9.5. Supabase просто поставив цю функцію в центр своєї моделі безпеки, бо його publishable ключ (у старіших проєктах він називається anon) їде в браузер разом із фронтендом. Будь-хто може відкрити інструменти розробника, скопіювати той ключ і надіслати власний запит до REST API вашого проєкту. Ключ повідомляє базі лише те, від імені якої ролі прийшов запит. Він не вирішує, які рядки ця роль має побачити. Це вирішує сама база, за політиками, які ви пишете руками.
Тому self-hosting Supabase на власному VPS дає контроль над даними, але не звільняє від цієї роботи. Сервер ваш, диск ваш, бекапи ваші. Політики все одно доведеться написати вам: це та частина контролю, яку ніхто не налаштує замість вас. Якщо ви ще тільки піднімаєте стек, почніть із розгортання Supabase на своєму VPS, а тоді повертайтесь сюди.
Нижче все показано на чистому PostgreSQL на Ubuntu 24.04: без Docker, без мережі, без Supabase. Так добре видно, де закінчується Postgres і де починається Supabase. А починається він лише в двох місцях: у трьох своїх ролях і у функції auth.uid().
Як anon ключ перекладає авторизацію на базу
Між браузером і Postgres у Supabase стоїть PostgREST. Він приймає HTTP-запит, бере з нього JWT (JSON web token, підписаний токен із заявами про користувача), перевіряє підпис секретом проєкту і відкриває транзакцію в базі. Усередині цієї транзакції він виконує приблизно SET LOCAL ROLE authenticated та SET LOCAL request.jwt.claims = '...'. Заяви токена стають звичайним параметром сесії.
Тобто вся автентифікація доходить до Postgres як дві прості речі: імʼя ролі та рядок JSON у налаштуванні сесії. Політика RLS бачить рівно це і нічого більше. Хто розуміє цей механізм, той розуміє RLS, бо далі все відбувається всередині SQL.
Ставимо Postgres на Ubuntu і піднімаємо кластер
sudo apt update
sudo apt install -y postgresql
pg_lsclustersПакет postgresql тягне поточну мажорну версію і одразу створює кластер із назвою main. pg_lsclusters показує версію, порт, стан і каталог даних. Візьміть номер версії з першої колонки, він потрібен наступній команді. Якщо кластер не запущений, запустіть його:
sudo pg_ctlcluster 16 main start
pg_lsclustersЗамініть 16 на свою версію, якщо вона інша. pg_ctlcluster це обгортка Debian та Ubuntu над pg_ctl. Вона сама знає шлях до конфігурації і до каталогу даних цього кластера, тому працює без додаткових аргументів. Далі створіть окрему базу для дослідів:
sudo -u postgres createdb rlsdemo
sudo -u postgres psql -d rlsdemoУсі наступні команди SQL виконуються в цій сесії psql, під роллю postgres.
Таблиця з колонкою власника і роль без зайвих прав
CREATE TABLE notes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id uuid NOT NULL,
body text NOT NULL
);
INSERT INTO notes (owner_id, body) VALUES
('11111111-1111-1111-1111-111111111111', 'перша нотатка Олени'),
('11111111-1111-1111-1111-111111111111', 'друга нотатка Олени'),
('22222222-2222-2222-2222-222222222222', 'нотатка Тараса');
CREATE ROLE app_user NOLOGIN;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO app_user;owner_id це колонка, за якою політика відрізнятиме рядки. У Supabase її зазвичай називають user_id і вішають зовнішній ключ на auth.users(id). Роль app_user відповідає ролі authenticated у Supabase: звичайна роль, не суперкористувач, не власник таблиці.
Права на таблицю (GRANT) і політики RLS це два різні шари, і Postgres проходить їх послідовно. Спершу перевіряються права. Якщо права немає, запит відхиляється і до політик справа не доходить. Якщо право є, вмикаються політики і відсіюють рядки. Такий поділ звичний кожному, хто вже роздавав окремі ролі з мінімально потрібними правами на своєму сервері: спочатку доступ до обʼєкта, потім межі всередині нього.
Що ця роль бачить до того, як зʼявилась політика
SET ROLE app_user;
SELECT id, owner_id, body FROM notes;
RESET ROLE;SET ROLE перемикає поточну роль усередині тієї самої сесії, тому окремий вхід із паролем не потрібен. Усі перевірки прав, включно з RLS, далі йдуть за новою роллю. Запишіть вивід цього запиту. Це ваша точка відліку, і кожен наступний крок ви порівнюватимете саме з нею.
Тепер увімкніть RLS і повторіть той самий запит, більше нічого не змінюючи:
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
SET ROLE app_user;
SELECT id, owner_id, body FROM notes;
RESET ROLE;Вивід змінився, і змінився він не через помилку. ENABLE ROW LEVEL SECURITY каже Postgres: для цієї таблиці кожен рядок має бути окремо дозволений якоюсь політикою. Політик поки що немає, тож дозвіл узяти нізвідки. Таблиця закривається першою, а шляхи до неї ви відкриваєте по одному.
Перша політика: портативний варіант на current_setting
CREATE POLICY notes_select_own
ON notes
FOR SELECT
USING (owner_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid);USING це фільтр. Postgres дописує цей вираз до кожного запиту на читання так, ніби ви самі додали ще одну умову в WHERE. Рядок, для якого вираз не дає true, просто не повертається. Помилки при цьому немає, бо з погляду запиту такого рядка не існує.
current_setting('app.current_user_id', true) читає параметр сесії, а другий аргумент true означає: не падати з помилкою, якщо параметр у цій сесії ще жодного разу не задавали. Тоді функція повертає NULL, порівняння з NULL не дає true, і жоден рядок не проходить. Але щойно параметр заданий бодай раз, він лишається відомим до кінця сесії, і після RESET або після завершення транзакції з SET LOCAL функція повертає вже не NULL, а порожній рядок. Саме тому у виразі стоїть NULLIF: без нього приведення порожнього рядка до uuid валить запит помилкою замість того, щоб тихо не повернути нічого. З NULLIF обидва випадки, і «ніколи не задавали», і «скинули після роботи», однаково дають NULL, тож політика ламається в безпечний бік, що саме те, чого ви хочете.
SET ROLE app_user;
SELECT set_config('app.current_user_id',
'11111111-1111-1111-1111-111111111111', false);
SELECT id, owner_id, body FROM notes;
RESET ROLE;Порівняйте вивід із точкою відліку. Потім підставте другий UUID і подивіться ще раз. Ось і весь RLS у роботі: одна таблиця, один текст запиту, різні відповіді для різних сесій.
У справжньому застосунку цей параметр ставить пул зʼєднань на початку кожної транзакції, і обовʼязково через SET LOCAL, щоб значення скинулось наприкінці транзакції. Без LOCAL значення залишиться у зʼєднанні, зʼєднання повернеться в пул, і наступний користувач почне роботу з чужим ідентифікатором. Це тиха помилка, яку не видно в тестах на одному користувачі.
Та сама політика у стилі Supabase
CREATE POLICY "Users can view their own notes"
ON notes
FOR SELECT
TO authenticated
USING ((SELECT auth.uid()) = owner_id);Логіка ідентична, змінився лише словник. TO authenticated обмежує політику однією роллю, тож роль anon під неї не підпадає взагалі. auth.uid() це функція зі схеми auth, яку створює сам Supabase. Усередині вона робить те саме, що й попередній приклад: читає параметр request.jwt.claims, дістає з нього поле sub і приводить до uuid.
Дужки навколо (SELECT auth.uid()) не косметика. У такій формі планувальник обчислює значення один раз на весь запит, а без дужок функція викликається знову і знову, поки Postgres перебирає таблицю, і на великій таблиці різниця в часі відповіді помітна. Так радить сама документація Supabase станом на вересень 2026.
Тримайте цю різницю в голові, коли обираєте форму. Політика на current_setting переживе будь-який переїзд, зокрема й на керований Postgres замість власного на VPS. Політика на auth.uid() працює тільки там, де існує схема auth.
USING проти WITH CHECK
USING перевіряє рядки, які вже лежать у таблиці, тож він діє на SELECT, UPDATE і DELETE. WITH CHECK перевіряє рядок, який зʼявиться після операції, тож він діє на INSERT і на нову версію рядка при UPDATE. Політика тільки з USING не дозволяє вставку, бо перевіряти новий рядок їй нічим.
CREATE POLICY notes_insert_own
ON notes
FOR INSERT
WITH CHECK (owner_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid);
CREATE POLICY notes_update_own
ON notes
FOR UPDATE
USING (owner_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid)
WITH CHECK (owner_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid);Два вирази в політиці на UPDATE роблять різну роботу. USING вирішує, які рядки ви взагалі маєте право редагувати. WITH CHECK дивиться на результат редагування. Пропустіть WITH CHECK, і користувач зможе переписати owner_id на будь-яке значення, бо результат не перевіряє ніщо: рядок піде до чужого власника, і власник про це не дізнається.
Спробуйте вставку від імені app_user з чужим owner_id і подивіться, що відповість Postgres. Запишіть текст цієї помилки дослівно. Саме його ви побачите в логах застосунку, коли політика спрацює на продакшені, і впізнати його за секунду дешевше, ніж шукати причину годину.
Політики складаються між собою. Кілька PERMISSIVE політик (це тип за замовчуванням) обʼєднуються через OR, тож достатньо однієї, яка дозволяє рядок. Політики з AS RESTRICTIVE додаються через AND і лише звужують дозволене. Через це нова політика ніколи не зменшує доступ сама собою, і «додам ще одну, щоб було суворіше» без слова RESTRICTIVE дає протилежний результат.
Хто обходить політики RLS
Політика діє не на всіх. Перевірте цей список, перш ніж вважати таблицю закритою.
- Суперкористувач. Роль
postgres, під якою ви виконували всіALTER, ігнорує RLS повністю. - Роль з атрибутом
BYPASSRLS. Її видно в\duабо в колонціrolbypassrlsтаблиціpg_roles. - Власник таблиці. За замовчуванням власник не підпадає під власні політики, і саме через це тест, зроблений «від себе», нічого не доводить.
- Роль
service_roleу Supabase, яку створено з атрибутомBYPASSRLSнавмисно.
Власника лікує один рядок:
ALTER TABLE notes FORCE ROW LEVEL SECURITY;Після цього політики діють і на власника теж. Суперкористувача не зупинить ніщо, і так і має бути: RLS захищає застосунок від його ж користувачів, а не базу від адміністратора.
Про service_role варто сказати окремо. Секретний ключ Supabase (sb_secret_..., у старіших проєктах це довгий JWT із роллю service_role) авторизує саме цю роль і обходить кожну вашу політику. Такий ключ живе тільки на сервері: у бекенді, в edge function, у CI. Ніколи в браузері, ніколи в мобільному застосунку, ніколи в гіті. Станом на вересень 2026 Supabase додатково відхиляє секретні ключі, впізнавши браузер за заголовком User-Agent, але це запобіжник від випадковості, а не межа безпеки.
У self-hosted стеку не шукайте ці ключі за чужими інструкціями. Вони згенеровані у вашому власному .env поруч із docker-compose.yml, і скрипт із того самого каталогу друкує їх командою sh run.sh secrets. Змінні називаються SUPABASE_PUBLISHABLE_KEY та SUPABASE_SECRET_KEY, а в стеках, розгорнутих раніше, ANON_KEY і SERVICE_ROLE_KEY. Той самий файл тримає JWT_SECRET і POSTGRES_PASSWORD, тож йому місце у власному сховищі секретів, а не в репозиторії з кодом.
Три симптоми, з якими приходять
Запити раптом повертають порожньо. RLS увімкнено, а політики на SELECT немає, або її USING не збігається з даними. Найчастіше винен не сам вираз, а те, що до бази не доїхали заяви токена: auth.uid() повертає NULL для анонімного запиту, і порівняння з NULL не дає true ніколи. Виконайте SELECT current_setting('request.jwt.claims', true); у тій самій транзакції і подивіться, що там насправді.
INSERT відхиляється, хоча GRANT INSERT виданий. Немає політики з WITH CHECK, або новий рядок їй не відповідає. Класичний випадок: клієнт не передає owner_id, поле лишається без потрібного значення, і перевірка не проходить. Лікується DEFAULT auth.uid() на колонці, щоб база сама підставляла власника.
Усі бачать усе. Або RLS не увімкнено на цій таблиці, або запит іде від власника таблиці чи від секретного ключа. Перевіряється двома запитами:
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class WHERE relname = 'notes';
SELECT polname, polcmd, polpermissive FROM pg_policy
WHERE polrelid = 'notes'::regclass;Перший каже, чи ввімкнено RLS і чи діє він на власника. Другий перелічує політики та їхній тип. Якщо ви перевіряли доступ у psql під роллю postgres, ви не перевірили нічого: додайте SET ROLE і повторіть.
Що робити з цим далі
Візьміть за правило вмикати RLS на кожній таблиці схеми public одразу після CREATE TABLE, ще до першої політики. Закрита таблиця ламає розробку голосно і рано. Відкрита таблиця мовчить доти, доки її не знайде хтось сторонній. Тримайте політики у файлах міграцій поруч зі схемою, щоб їх було видно на ревʼю коду, і перевіряйте кожну так, як тут: SET ROLE, той самий запит, порівняння виводу.
Якщо ви ще обираєте між хмарою і власним сервером, різницю в тому, що саме переходить під ваш контроль, розібрано в порівнянні безкоштовного Supabase Cloud і self-hosted стеку. Політики ви писатимете однаково в обох варіантах. Відрізняється тільки те, хто тримає в руках ключ, який ці політики обходить.
Прибрати за собою після дослідів
sudo -u postgres dropdb rlsdemo
sudo -u postgres psql -c 'DROP ROLE app_user;'Роль створена на рівні кластера, тому видалення бази її не прибирає. Якщо роль десь ще має права, DROP ROLE відмовиться і назве обʼєкт, який їх тримає.
FAQ
Чи працює RLS без Supabase?
Так. Row level security це функція самого PostgreSQL, доступна з версії 9.5, і весь приклад вище виконується на пакеті postgresql з Ubuntu без жодного компонента Supabase. Supabase додає зверху три ролі (anon, authenticated, service_role) і схему auth із функціями auth.uid() та auth.jwt(). Якщо писати політики через current_setting, вони переносяться на будь-який Postgres без змін.
Чому auth.uid() повертає NULL?
Тому що в поточній транзакції немає заяв токена. Так буде для запиту з publishable ключем без входу користувача, для запиту через прямий psql, а також коли ваш власний код відкриває зʼєднання в обхід PostgREST. Перевірте SELECT current_setting('request.jwt.claims', true); у тій самій транзакції. Пишіть політики так, щоб NULL означав відмову, і додавайте TO authenticated, щоб роль anon під політику не підпадала.
Чи можна покластися лише на RLS і не перевіряти доступ у застосунку?
Для даних це достатній рубіж, і він надійніший за перевірку в коді, бо діє на кожен шлях до таблиці. Але у нього є рівно один спосіб обходу, про який треба памʼятати: будь-який запит із секретним ключем або від суперкористувача проходить повз політики. Тому правило просте. Усі запити від клієнта йдуть із publishable ключем, а кожне місце, де використано секретний ключ, перевіряє права самостійно.
Чому мій запит бачить усі рядки, хоча політика створена?
Бо запит виконується від ролі, на яку RLS не діє. Власник таблиці за замовчуванням не підпадає під свої політики, і суперкористувач теж. У psql під роллю postgres ви перевіряєте саме цей випадок. Використайте SET ROLE на звичайну роль перед запитом, а для таблиць, які читає їхній власник у проді, додайте ALTER TABLE ... FORCE ROW LEVEL SECURITY.