Postgres tables with row-level security disabled or misconfigured
why does my supabase table have no row level security and can anon read everything Yes - if relrowsecurity is off, or RLS is on with zero policies, or the app connects as the postgres superuser, your data is either fully exposed to the anon role or fully locked out. Check with a query against pg_class and information_schema.role_table_grants, then enable RLS, write explicit policies, and revoke blanket grants from anon.
Seen in 19 of 450 scanned projects (4%). Based on read-only psql scans of self-hosted Supabase and Postgres projects across 450 audited repositories.
How to tell you have it
- Any browser with the anon or publishable key can read or write rows that should be private
- A table works fine from the service role but returns empty results or 401s from the client SDK
- New tables created via migration never got an RLS policy attached
- psql shows relrowsecurity = f on tables holding user data
- The app's DATABASE_URL connects as postgres or a role with BYPASSRLS
Why it matters
Row-level security is the only thing standing between a public anon key and your raw table data once you expose PostgREST or the Supabase client to a browser. If RLS is disabled on a table and the anon role has SELECT or INSERT grants, which is the default in self-hosted setups that copy the sample schema, anyone with the publishable key can query that table directly with curl, no auth token required. This is one of the most common ways user data leaks from side projects that never went through a real security review.
The opposite failure looks safer but breaks the product: enabling RLS on a table and then forgetting to add any policy. Postgres denies all access by default once RLS is turned on with no policies present, so the table effectively vanishes for every role except the table owner and superuser roles. Support tickets show up as 'nothing loads' or silent empty arrays, and it is easy to misdiagnose this as a frontend bug rather than a missing policy, wasting hours in the wrong file.
A third variant hides RLS entirely: the application's connection string authenticates as the postgres superuser, or as a custom role with the BYPASSRLS attribute. Superuser and BYPASSRLS roles ignore every policy on every table, so RLS can look perfectly configured in pg_policies and still do nothing, because the app never actually goes through the policy engine. This is common when a project's DATABASE_URL was copied from the initial database setup and never rotated to a scoped app role.
The blast radius scales with how much PII or billing data sits in the affected tables. A missing policy on an orders or profiles table is not a theoretical finding, it is a direct path to a data breach disclosure, and regulators and customers do not distinguish between 'we forgot a policy' and 'we got hacked'. Fixing this after the fact also means auditing access logs to figure out whether the exposure was ever exploited, which is far more expensive than catching it before the table went live.
How Heygents detects it
Deep Scan connects with psql using the project's own DATABASE_URL from its .env and queries pg_class.relrowsecurity for every table in the public schema, cross-references information_schema.role_table_grants for the anon and authenticated roles, and checks pg_policies for tables where RLS is enabled but has zero rows. It also checks whether the connecting role has the superuser or bypassrls attribute via pg_roles.
How to fix it
- List every table missing RLS or missing policies Run this against the project database to get a single report of tables with RLS off, tables with RLS on but no policies, and which grants anon currently holds. Start every remediation from this list so nothing gets missed.
- Check what the anon role can actually do This shows every grant the anon role holds directly, independent of RLS. If anon has INSERT, UPDATE, or DELETE on a table that has no RLS, treat it as a live incident, not a backlog item.
- Enable RLS and revoke the blanket grant Turn on RLS for the table and strip the default anon grant that self-hosted setups often leave in place. Do this in the same migration so there is never a window where RLS is on but the old grant still lets anon bypass it via a role with table-level privileges but no matching policy.
- Write an explicit policy instead of leaving RLS empty RLS with no policy denies everything, which breaks the app just as badly as no RLS breaks security. Write the narrowest policy that satisfies the actual access pattern, usually scoped to the authenticated user's own rows.
- Move the app off the postgres superuser connection Create a least-privilege app role without BYPASSRLS and point DATABASE_URL at it. Confirm the role's attributes before rotating any secret, since a role created with BYPASSRLS set will silently ignore every policy you just wrote.
- Verify no role bypasses RLS After rotating the connection role, confirm neither the app role nor any role it inherits from has superuser or bypassrls set. This catches the case where a role looks scoped but was granted membership in a privileged group role.
List every table missing RLS or missing policies
SELECT c.relname AS table_name,
c.relrowsecurity AS rls_enabled,
COALESCE(p.policy_count, 0) AS policy_count
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN (
SELECT tablename, count(*) AS policy_count
FROM pg_policies
GROUP BY tablename
) p ON p.tablename = c.relname
WHERE n.nspname = 'public'
AND c.relkind = 'r'
ORDER BY rls_enabled ASC, policy_count ASC;
Check what the anon role can actually do
SELECT table_name, privilege_type
FROM information_schema.role_table_grants
WHERE grantee = 'anon'
AND table_schema = 'public'
ORDER BY table_name;
Enable RLS and revoke the blanket grant
ALTER TABLE <table> ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON <table> FROM anon;
GRANT SELECT ON <table> TO anon;
Write an explicit policy instead of leaving RLS empty
CREATE POLICY "select_own_rows" ON <table>
FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "insert_own_rows" ON <table>
FOR INSERT
WITH CHECK (auth.uid() = user_id);
Move the app off the postgres superuser connection
CREATE ROLE app_user LOGIN PASSWORD '<strong-generated-password>' NOSUPERUSER NOBYPASSRLS;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
-- then update /var/www/<app>/.env:
-- DATABASE_URL=postgres://user:pass@host:5432/db
Verify no role bypasses RLS
SELECT rolname, rolsuper, rolbypassrls
FROM pg_roles
WHERE rolname IN ('app_user', 'anon', 'authenticated', 'postgres');
Find this in your own projects, automatically
Heygents runs a read-only Deep Scan across every project you own, finds issues like this one, and hands you a ready-to-run fix an AI agent can execute and verify. A solo developer gets the audit, the backlog and the fix loop in one place.
Open Heygents →Frequently asked questions
Does enabling RLS automatically block all access to a table?
Yes, until you add at least one policy. Postgres treats RLS as default-deny, so a table with relrowsecurity true and zero rows in pg_policies is unreadable by any role except the table owner and superuser roles. If your app suddenly returns empty results after an RLS migration, check pg_policies before assuming the frontend broke.
Why is RLS disabled on tables I did not create manually?
Most self-hosted Postgres and early Supabase setups create tables through a schema script or ORM migration that never calls ENABLE ROW LEVEL SECURITY. RLS is off by default in vanilla Postgres, so unless your migration tooling explicitly turns it on per table, every new table ships wide open to whatever role holds SELECT grants, including anon.
Does the service role key bypass RLS the same way a superuser connection does?
Yes. The service role in Supabase is configured with BYPASSRLS specifically so backend jobs can operate without policy friction. That is fine for trusted server code, but it must never be shipped to a browser or mobile client, since anyone holding it gets unrestricted read and write access to every table regardless of policies.
How do I test that a policy actually restricts access before deploying it?
Connect as the anon or authenticated role directly, not as postgres, and run SET ROLE authenticated followed by SET request.jwt.claims to simulate a specific user, then run the query your app would run. If it returns rows it should not, the policy's USING clause is too permissive and needs a tighter condition.