Skip to content
React+Supabase RLS
Stack Integration

React + Supabase Row-Level Security

Row-Level Security turns your database into the authorization layer. Your React frontend calls Supabase directly — RLS ensures users can only see their own data.

Use Cases
  1. Multi-tenant SaaS where each customer sees only their records
  2. Per-user data isolation without a custom API layer
  3. Role-based access enforced at the DB
  4. Audit-trail tables that only admins can read
Implementation

RLS policies run in Postgres, not in your application code. JWT from Supabase Auth carries the user ID → RLS policy checks auth.uid() against a user_id column. No server-side API needed for most CRUD.

In detail

How the pieces connect

There is no API server in the middle. The React client holds a Supabase client created with createClient(url, anonKey), and every supabase.from('table').select() call is a PostgREST request sent straight to Postgres over HTTPS. After signInWithPassword or an OAuth flow, Supabase Auth returns a JWT that the client library attaches as the Authorization: Bearer header on each request. Postgres reads the sub claim from that JWT and exposes it as auth.uid() inside the session. RLS policies you defined with CREATE POLICY then run on every row touched, so the database itself decides what the query returns. The anon key is public and safe in the bundle precisely because RLS, not the key, is the gate. Without an enabled policy, a table with RLS on returns zero rows rather than failing open.

Production gotchas

The most common bug is shipping a table with ALTER TABLE ... ENABLE ROW LEVEL SECURITY but no policy — reads silently return [] and writes get rejected, which looks like a frontend bug. Equally common is the reverse: a table where RLS was never enabled, so the public anon key reads everything. Write a separate policy per command (SELECT, INSERT, UPDATE, DELETE); a SELECT policy does not cover inserts, and INSERT policies need WITH CHECK, not USING. The session JWT expires (one hour by default) — the client refreshes it via the refresh token, but if you cache a Supabase client across a stale token or read auth.uid() in a policy without it being present, rows vanish. Test policies as a real user with supabase.auth.getSession(), never with the service-role key, which bypasses RLS entirely and must stay server-side.

Writing policies that scale past one table

Simple ownership is auth.uid() = user_id in the policy's USING clause. Multi-tenant and role-based access need more: put the tenant id or role in a profiles or memberships table and reference it, but wrap the lookup in a SECURITY DEFINER helper function or you risk infinite recursion when a policy queries a table that itself has RLS. Custom claims set in the JWT can be read with auth.jwt() -> 'app_metadata', which avoids an extra table read on every row. Index the columns your policies filter on (user_id, tenant_id) — RLS predicates run per row, so an unindexed USING clause turns a fast query into a sequential scan once tables grow. Keep policy logic small and push heavy checks into indexed columns.

When this combo fits, and when it does not

Direct React-to-Supabase with RLS fits well when the data model maps cleanly to per-user or per-tenant ownership: dashboards, internal tools, multi-tenant SaaS where each customer reads only their rows. Pizzeria Bestek runs on React and Supabase, so this is the stack I work in directly. Where it stops fitting: logic that must stay private (pricing rules, third-party API keys), operations that touch many tables atomically, or anything driven by a webhook from Stripe or a payment provider — those belong in a Supabase Edge Function or a Postgres function called via RPC, where you can use the service role deliberately and verify signatures. Anything a malicious client could forge by editing the request should never depend on client-supplied values; it belongs behind RLS or in server code, not in the React layer.

Live ExampleCallidus — Case Study
Other integration guidesView all →
Related

Need this built?