· 8 min read · Nadeera Silva
Supabase RLS Explained: Grants, Policies, Safegres Audit
Learn supabase rls: how Postgres grants differ from RLS after Oct 30 2026, auth.uid() policies, and CI audits with Safegres 1.30.7.

If you ship a SaaS on Supabase, supabase rls is the layer that decides which rows a JWT can see. Grants decide whether a role can touch a table at all. Row Level Security (RLS) decides which rows survive. Confusing those two layers is how empty results, sudden 42501 errors, and open tables show up in production.
On 30 October 2026 Supabase finished rolling out a breaking change: new tables in public are no longer exposed to the Data API by default. You need an explicit GRANT before PostgREST can see them. A week later, on 6 November 2026, Constructive announced Safegres, an MIT open-source verifier that audits grants and RLS together on a live Postgres catalog (npm safegres@1.30.7). This post explains both layers with practical auth.uid() policies, then shows how to gate them in CI.
What is RLS in Supabase?
Postgres Row Level Security attaches policies to a table. Once you run ALTER TABLE ... ENABLE ROW LEVEL SECURITY, every query through a non-bypass role is filtered by those policies. Supabase documents this as the defense-in-depth layer for the Data API: think of each policy as an automatic WHERE clause that Postgres adds for you.
A typical owner-only select policy looks like this (from the Supabase RLS guide):
create policy "Individuals can view their own todos."
on todos for select
to authenticated
using ( (select auth.uid()) = user_id );That becomes, in effect:
select *
from todos
where auth.uid() = todos.user_id;auth.uid() returns the user id from the JWT. Wrap it in (select auth.uid()) so Postgres can cache the value once per statement (InitPlan) instead of calling the helper on every candidate row.
Grants vs RLS after the October 30 change
Postgres runs two checks before a client touches a table:
| Layer | Question it answers | Failure mode |
|---|---|---|
| GRANT | Can this role run SELECT / INSERT / UPDATE / DELETE on the table? | 42501 permission denied, with a PostgREST hint naming the missing grant |
| RLS | Which rows does that operation apply to? | Empty result set, or 42501 on a WITH CHECK violation |
Before the platform change, new tables in public often received default privileges for anon, authenticated, and service_role. RLS was still required, but the grant layer was easy to ignore. Supabase's changelog for the default-grant change states the new rule clearly: grants control whether a role can access a table at all; RLS controls which rows that role sees. Adding policies does not revoke grants. A missing grant fails before any policy runs.

Treat create-table, grant, enable-RLS, and policies as one migration unit:
-- 1. Privileges the Data API roles actually need
grant select on public.reports to anon;
grant select, insert, update, delete on public.reports to authenticated;
grant select, insert, update, delete on public.reports to service_role;
-- 2. Turn on RLS (deny-by-default until policies exist)
alter table public.reports enable row level security;
-- 3. Policies that match the product rules
create policy "users can read their own rows"
on public.reports
for select
to authenticated
using ( (select auth.uid()) = user_id );If you also verify JWTs at the edge, pair this with the same auth story you use for Fastify or other Node APIs: our verify Supabase JWT with Fastify JWKS post covers signature checks before the request ever reaches PostgREST.
Practical RLS policy examples with auth.uid()
Use one policy per operation. Name the role with TO. Prefer (select auth.uid()) over bare auth.uid().
Profiles owned by the signed-in user
create table public.profiles (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id),
display_name text,
avatar_url text
);
alter table public.profiles enable row level security;
revoke all on table public.profiles from anon, authenticated;
grant select, insert, update, delete on table public.profiles to authenticated;
create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Users can create their own profile"
on public.profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Users can delete their own profile"
on public.profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );
create index profiles_user_id_idx on public.profiles using btree (user_id);Public-read feed, authenticated writes only
alter table public.announcements enable row level security;
revoke all on table public.announcements from anon, authenticated;
grant select on table public.announcements to anon, authenticated;
grant insert, update, delete on table public.announcements to authenticated;
create policy "Anyone can read announcements"
on public.announcements for select
to anon, authenticated
using ( true );
create policy "Signed-in users can write announcements"
on public.announcements for insert
to authenticated
with check ( (select auth.uid()) = author_id );UPDATE needs SELECT too
Postgres needs a matching select policy for updates to find candidate rows. If updates silently match zero rows, check both the grant and the select policy before rewriting the update policy.
For multi-tenant or shared tables, assert allow and deny with supabase test db (pgTAP) the same way you would for profiles. And if your SaaS also stores embeddings, keep vector indexes patched: see pgvector 0.8.7 CVE upgrade and RAG with citations on pgvector.
Audit grants and RLS in CI with Safegres
Safegres is a static analyzer for a live PostgreSQL database. It reads the system catalog, parses every USING / WITH CHECK expression with the real Postgres grammar, and scores security and performance on separate axes. Constructive's 6 November 2026 PR Newswire release positions it for CI and for agent-written schemas: read-only, deterministic grades, no AI judge. The published npm package is `safegres@1.30.7` (MIT), and the code lives in the constructive-io/constructive monorepo.
Useful properties for a Supabase team:
- Preset
safegres:supabase(also PostgREST, Hasura, Graphile, Constructive) - Flags grants on tables with RLS disabled (rule A2), trivially permissive write policies (A7), missing policy coverage, and untrusted-role write grants
- Performance axis (
--perf) for unindexed policy columns and per-row volatile helpers - Markdown, JSON, and SARIF output for GitHub summaries and code scanning
Install and smoke-check
npm i -D safegres@1.30.7
npx safegres --version
# safegres 1.30.7
# Standard libpq env: PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE
npx safegres doctor
npx safegres audit --preset supabase --perf --format markdownPoint it at a staging replica or a CI database after your migrations apply. Safegres does not execute application queries and does not write to the catalog.
Minimal GitHub Actions job
Apply your schema the same way you already do in solo SaaS CI/CD, then audit:
jobs:
database-audit:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-retries 10
ports: ['5432:5432']
env:
PGHOST: localhost
PGUSER: postgres
PGPASSWORD: postgres
PGDATABASE: app_audit
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
# Apply migrations with your existing tool, for example:
# npx supabase db reset --db-url "$DATABASE_URL"
# or: psql "$DATABASE_URL" -f supabase/schema.sql
- run: npx safegres@1.30.7 audit --preset supabase --perf --format markdown >> "$GITHUB_STEP_SUMMARY"
- run: |
npx safegres@1.30.7 audit --preset supabase --perf \
--fail-on-grade B \
--fail-on-perf-grade CA project config can pin the stack preset so the job stays one command:
{
"$schema": "https://raw.githubusercontent.com/constructive-io/constructive/main/packages/safegres/schema/safegres.schema.json",
"extends": ["safegres:supabase", "safegres:recommended"],
"failOn": { "grade": "B" },
"perf": { "enabled": true }
}Checklist before you merge a schema change
- New
publictable includes explicitGRANTstatements for the roles that should reach the Data API ENABLE ROW LEVEL SECURITYships in the same migration as the grants- One policy per operation, with
TO anon/TO authenticatednamed - Owner filters use
(select auth.uid()) = user_id(or your tenant key) and a matching btree index - No
USING (true)write policies; public reads are intentional and grant-narrow service_role/ secret keys stay server-side- CI runs
safegres audit --preset supabase(and--perfonce the schema is stable enough to gate)
What to do next
If an AI coding agent or MCP flow creates tables for you, update its prompt or skill so grants, RLS, and policies stay in one change. Constructive also ships a Safegres agent skill (npx skills add constructive-io/constructive --skill safegres). Agents that see a failing grade in CI can fix policies without inventing a second security model in application code.
I am not claiming Safegres replaces Security Advisor or your own pgTAP suite. I am saying grants and RLS are two different doors, Supabase now forces the grant door to be explicit, and a read-only catalog auditor is the right place to catch the mistakes that slip past a dashboard click.
Frequently asked questions
What is RLS in Supabase?
RLS means Postgres Row Level Security. After you enable it on a table, policies act like automatic WHERE clauses that filter which rows each role can select, insert, update, or delete through the Data API.
How do grants differ from RLS policies?
Grants decide whether a role can run an operation on a table at all. RLS policies decide which rows that operation applies to. A missing grant returns permission denied before any policy runs.
What changed for Supabase tables on October 30 2026?
New tables in the public schema are no longer exposed to the Data API by default. You must issue explicit GRANT statements for anon, authenticated, or service_role before PostgREST can see them. Existing tables keep their prior grants.
How should I use auth.uid() in an RLS policy?
Prefer (select auth.uid()) = user_id so Postgres can cache the JWT user id once per statement. Name the role with TO authenticated, write one policy per operation, and index the filtered column.
What is Safegres and how do I run it in CI?
Safegres is Constructive's MIT open-source Postgres grants and RLS auditor (npm safegres 1.30.7). After migrations, run npx safegres audit --preset supabase --perf and fail the job below a grade you can hold.
Does enabling RLS remove default grants?
No. Adding policies does not revoke grants. Revoke privileges you do not want, grant back only what each role needs, enable RLS, then add policies in the same migration.
Sources
- Constructive Releases Safegres, an Open-Source Verifier for Secure Postgres (PR Newswire, 2026-11-06)
- safegres, Safe and fast Postgres. Graded. (Constructive)
- safegres on npm (1.30.7) (npm, 2026-11-05)
- constructive-io/constructive (GitHub)
- Breaking Change: Tables not exposed to Data and GraphQL API automatically (Supabase, 2026-04-28)
- Row Level Security (Supabase)
- PostgreSQL DDL: Row Security Policies (PostgreSQL)
Keep reading
- How to Verify a Supabase JWT in Fastify with JWKSVerify Supabase JWTs in a Fastify API with JWKS and jose, handle the legacy JWT secret, rotate keys safely, and secure Socket.IO connections too.
- pgvector 0.8.7 Security Fix: Upgrade, Then Pick HNSW or IVFFlatpgvector 0.8.7 fixes a high-severity IVFFlat bug. Check your version, upgrade on Docker or Supabase, and choose between HNSW and IVFFlat.
- RAG with Citations on PostgreSQL and pgvector: How I Built ItHow I built RAG with citations on PostgreSQL and pgvector for Pactroom: schema, project-scoped retrieval, citation prompts and the HNSW filtering trap.
Comments
Share a question or note about this article.
Loading comments…