SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Home/Security/Missing or Disabled RLS Policy
CWE-862Critical severitySupabase

Missing or Disabled RLS Policy

✓SecureStartKit enables Row Level Security on every table by default and ships with a deny-by-default posture.

Last reviewed June 13, 2026 by SecureStartKit Team

The short answer

Every Supabase table exposed through the API must have Row Level Security enabled. Without it, any request carrying the anon key (which ships to the browser) can read or write every row. Enabling RLS with no policies produces a safe deny-all default. Add least-privilege policies scoped to auth.uid() to grant access only to the rows a user owns.

Where it shows up: A table holding user data has RLS disabled, or has a policy whose USING expression is not scoped to the current user (for example USING (true)), allowing the anon or authenticated role to read or modify every row.

The vulnerable patterns and their fixes

Table created with RLS never enabled

✗Vulnerablesql
-- Table created with no RLS configuration.
-- The anon key can SELECT every row via the REST API.
create table orders (
  id          uuid primary key default gen_random_uuid(),
  user_id     uuid references auth.users(id),
  total_cents integer not null,
  created_at  timestamptz default now()
);

-- RLS is OFF. No policies exist.
-- Any request with the anon key returns all rows.

RLS is disabled by default when a table is created. Without an explicit ALTER TABLE ... ENABLE ROW LEVEL SECURITY, PostgREST applies no row filter and every row is visible to the anon role.

↓the fix
✓Securesql
create table orders (
  id          uuid primary key default gen_random_uuid(),
  user_id     uuid references auth.users(id),
  total_cents integer not null,
  created_at  timestamptz default now()
);

-- Step 1: enable RLS. With no policies this is deny-all (safe default).
alter table orders enable row level security;

-- Step 2: grant read access only to the row owner.
create policy "Users read their own orders"
  on orders for select to authenticated
  using (auth.uid() = user_id);

-- Step 3: allow insert only for the user's own rows.
create policy "Users insert their own orders"
  on orders for insert to authenticated
  with check (auth.uid() = user_id);

Enabling RLS produces a deny-all baseline. Each explicit policy then grants the minimum access needed: a user can only SELECT or INSERT rows where user_id matches their own auth.uid(). The anon role receives no policy, so unauthenticated requests are rejected.

RLS enabled but the policy uses USING (true)

✗Vulnerablesql
alter table user_profiles enable row level security;

-- Looks like RLS is on, but the predicate grants access to everyone.
create policy "Allow all reads"
  on user_profiles for select to authenticated
  using (true);

-- Result: any authenticated user can read every profile.

USING (true) always evaluates to true, so RLS is technically enabled but provides no access control. Any authenticated user can read all rows, including those belonging to other users.

↓the fix
✓Securesql
alter table user_profiles enable row level security;

drop policy if exists "Allow all reads" on user_profiles;

-- Replace with an owner-scoped policy.
create policy "Users read their own profile"
  on user_profiles for select to authenticated
  using (auth.uid() = id);

create policy "Users update their own profile"
  on user_profiles for update to authenticated
  using (auth.uid() = id)
  with check (auth.uid() = id);

The USING clause is replaced with auth.uid() = id. PostgREST evaluates this predicate per row, so only the row whose id matches the requesting user passes the filter. All other rows are invisible.

SecureStartKit ships these defenses by default. RLS, Zod-validated Server Actions, and verified webhooks, already wired in.

Get SecureStartKit→

How it’s exploited

Supabase exposes every table in the public schema over a PostgREST HTTP endpoint at a URL of the form https://PROJECT.supabase.co/rest/v1/TABLE. Requests are authenticated by including the project anon key in the Authorization header. That anon key is public: it ships inside your front-end JavaScript bundle and can be extracted by any visitor in seconds using browser DevTools.

When RLS is disabled on a table, PostgREST applies no row-level filter at all. An attacker can issue a plain GET request to the endpoint and receive every row in the table. If INSERT or UPDATE is also unrestricted, they can write arbitrary data. No account and no privilege beyond the public anon key is required.

The attack is not exotic. A single curl request against the endpoint, passing the anon key as the apikey header, is enough. If RLS is off, the response is a full dump of every row in the table.

The same risk applies when RLS is enabled but a policy is written as USING (true). That expression always evaluates to true for every role, so all rows pass the filter. The presence of a policy is not sufficient: the predicate must be correctly scoped.

The service_role key bypasses RLS entirely by design. Server-side code that uses createAdminClient() (which carries the service_role key) must therefore perform its own authorization checks before returning or mutating data. Never leak the service_role key to the browser.

How to find it in your code

The Supabase dashboard shows RLS status on the Table Editor page. A table marked RLS disabled is fully open to the API. Review every table in the public schema.

For a programmatic audit, run this query in the Supabase SQL Editor:

select schemaname, tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;

Any row where rowsecurity is false is a table with RLS off. Then list existing policies to check for overly permissive predicates:

select tablename, policyname, cmd, qual
from pg_policies
where schemaname = 'public'
order by tablename;

A qual value of true, or a null qual on an unrestricted policy, is a finding. Review every policy whose USING or WITH CHECK expression does not reference auth.uid() or auth.jwt().

For server-side code that uses createAdminClient() (service_role key), RLS is bypassed at the database level. Audit each call site to confirm the application layer enforces ownership before returning or mutating data. A grep for createAdminClient in the actions directory is a reasonable starting point.

Common mistakes

  • Myth“The anon key is not really public because I never display it on the page.”

    The anon key is embedded in the JavaScript bundle shipped to the browser. Any visitor can find it in seconds by opening the Network tab or searching the page source. Design your security posture assuming the anon key is always known to attackers.

  • Myth“Enabling RLS is enough. I do not need to add any policies.”

    Enabling RLS with no policies is a safe deny-all default, but your application will break because no role can read or write any rows until you add explicit policies. Enabled with no policies is a safe starting point, not a finished configuration.

  • Myth“My service_role key is only used server-side, so RLS does not matter for those queries.”

    The service_role key bypasses RLS entirely. If your server-side code fetches rows with createAdminClient() and no ownership check, a user who can trigger that code path can read or modify rows they do not own. RLS and server-side authorization are complementary, not interchangeable.

  • Myth“USING (auth.uid() IS NOT NULL) is a safe policy because it requires login.”

    That expression is true for any authenticated user regardless of which row is accessed. It is functionally identical to USING (true) for authenticated requests. The policy must compare auth.uid() against the row owner column: USING (auth.uid() = user_id).

Does SecureStartKit prevent this?

The kit supabase/schema.sql runs ALTER TABLE ... ENABLE ROW LEVEL SECURITY for every user-data table before any policy is added, so a freshly created table is locked to the anon and authenticated roles until an explicit policy is written. Server Actions use createAdminClient() (service_role), which bypasses RLS, but every action validates the authenticated session and scopes queries to the current user before returning data.

How the kit enables RLS by default→

Frequently asked questions

Is it safe to use the anon key in browser code?
Yes, the anon key is intentionally public. Supabase designed it to be exposed to browsers. Security comes from RLS policies that restrict what the anon role can access, not from keeping the key secret. The service_role key, by contrast, must never be sent to the browser.
What happens if I enable RLS but add no policies?
The table becomes inaccessible to the anon and authenticated roles. All operations through the API return an empty result or a permission-denied error. This is the safe default. Your application stops working until you add the appropriate policies, which is the intended behavior.
My table is only written to by server-side Server Actions. Do I still need RLS?
Yes. If the table exists in the public schema and is reachable through the PostgREST endpoint, an attacker can call it directly, bypassing your Server Actions entirely. RLS is a database-level control that applies regardless of how the request reaches Postgres. Always enable it.
Can I test my RLS policies before deploying to production?
Yes. In the Supabase SQL Editor, use SET ROLE authenticated followed by setting the request.jwt.claims for a specific user, then run a SELECT to simulate that user. This verifies your USING expressions filter rows correctly before the policy reaches production.

References

  • CWE-862: Missing Authorization ↗
  • Supabase Row Level Security guide ↗

Related weaknesses

  • IDOR: Missing Ownership CheckA Server Action or route handler reads or writes a record using createAdminClient() with only an id filter and no ownership filter. Because service_role skips Row Level Security, any authenticated user can access any row by supplying an arbitrary id.
  • Exposed Supabase service_role KeyThe service_role key appears in browser DevTools under the Network tab or Sources panel, is readable in your built JavaScript bundle, is committed to a git repository, or is returned inside an API response body.
  • SQL Injection in Supabase QueriesUser input is interpolated into a .or() or .filter() string, or concatenated into a Postgres function’s dynamic SQL, instead of being passed as a bound value.

Defined terms

  • Row Level Security
  • service_role key
  • anon key

Go deeper

  • Supabase RLS Policies That Actually Work
  • RLS Policy Generator

Ship these defenses by default

SecureStartKit is a Next.js, Supabase, and Stripe starter with Row Level Security, Zod-validated Server Actions, verified Stripe webhooks, and backend-only data access already wired in. Start from a secure baseline instead of hardening by hand.

Get SecureStartKit→Browse all patterns
← Back to all security patterns