Skip to content

· 8 min read · Nadeera Silva

Web & SaaS

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.

Soft clay teal database cylinder with layered shelves of teal and amber row blocks, a teal gate filtering amber blocks through, and a small magnifying-glass checklist beside it representing an RLS policy audit.

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:

LayerQuestion it answersFailure mode
GRANTCan this role run SELECT / INSERT / UPDATE / DELETE on the table?42501 permission denied, with a PostgREST hint naming the missing grant
RLSWhich 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.

Horizontal flow from Client through PostgREST, GRANT check, RLS policies, and filtered rows, with callouts for the table gate versus the row filter
Grants are the table gate; RLS policies filter which rows pass

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 markdown

Point 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 C

A 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 public table includes explicit GRANT statements for the roles that should reach the Data API
  • ENABLE ROW LEVEL SECURITY ships in the same migration as the grants
  • One policy per operation, with TO anon / TO authenticated named
  • 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 --perf once 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

  1. Constructive Releases Safegres, an Open-Source Verifier for Secure Postgres (PR Newswire, 2026-11-06)
  2. safegres, Safe and fast Postgres. Graded. (Constructive)
  3. safegres on npm (1.30.7) (npm, 2026-11-05)
  4. constructive-io/constructive (GitHub)
  5. Breaking Change: Tables not exposed to Data and GraphQL API automatically (Supabase, 2026-04-28)
  6. Row Level Security (Supabase)
  7. PostgreSQL DDL: Row Security Policies (PostgreSQL)
SharePost on XLinkedIn

Comments

Share a question or note about this article.

Loading comments…