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.
-- 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.
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.
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.
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.
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.
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.
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).
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