{"slug": "supabase-rls-not-enabled-what-it-means-and-how-to-fix-it", "title": "Supabase RLS not enabled: what it means and how to fix it", "summary": "A security analysis of Supabase-backed applications found that missing or misconfigured Row Level Security (RLS) is widespread, with the 2026 vibe-eval benchmark reporting 59% of sampled apps had at least one RLS gap and 10.3% of sampled Lovable apps exposed real user data. Because the Supabase project URL and anon key are public by design, the analysis notes, tables without RLS policies are readable by unauthenticated users, and it catalogs common failure patterns including permissive `using (true)` policies, direct grants to the anon role, and `security definer` functions without a pinned search path. The writeup recommends enabling RLS per table, writing scoped policies tied to `auth.uid()`, and setting `search_path` on elevated functions.", "body_md": "Your app is live. The URL works, the database has real rows in it, and somebody has just opened the browser developer tools, found the Supabase project URL and the public \"anon\" key that your frontend is built to expose, and sent one request to your database. If that request comes back with your customers' names, emails and addresses, your tables have no Row Level Security. The key was never the problem.\n\nThis is not a rare failure. In the 2026 vibe-eval benchmark, 59% of the apps sampled had at least one RLS gap, and 10.3% of sampled Lovable apps exposed real user data through missing Row Level Security. symbioticsec.ai reported that 98% of apps behind a public Supabase URL had at least one exploitable flaw.\n\nThe design explains why this happens. The project URL and the anon key are public on purpose. They ship inside your JavaScript bundle, and Supabase grants the anon role access to your tables by default. Your only defence is a rule inside Postgres that says which signed-in user may read which row. If that rule was never written, the public key reads everything.\n\nThe scanner reads your SQL migrations. This is the pattern it finds:\n\n```\ncreate table public.profiles (\n  id uuid primary key references auth.users (id) on delete cascade,\n  full_name text,\n  email text,\n  phone text,\n  role text default 'user',\n  company text,\n  created_at timestamptz default now()\n);\n```\n\nA table holding names, emails and phone numbers, and no `enable row level security` anywhere in the file. Postgres does not filter rows for that table at all, so the anon role gets whatever the default grants give it.\n\nThree more shapes count as the same problem.\n\n**RLS is on, but the policy underneath allows everything.**\n\n```\nalter table public.orders enable row level security;\n\ncreate policy \"anyone can read orders\" on public.orders\n  for all\n  using (true)\n  with check (true);\n```\n\nThe table looks protected in the dashboard. The rule allows every row for every role, so the effect is identical to having no rule.\n\n**Access granted straight to the public role.**\n\n```\ngrant all on table public.profiles to anon;\n```\n\nThat hands the unauthenticated role full access to a table with no RLS on it.\n\n**A function that runs with admin rights and no pinned search path.**\n\n```\ncreate or replace function public.get_company_stats(company_id uuid)\nreturns jsonb\nlanguage plpgsql\nsecurity definer\nas $$\ndeclare\n  result jsonb;\nbegin\n  select jsonb_build_object(\n    'employees', (select count(*) from public.profiles where role = 'employee'),\n    'plan', (select plan from public.companies where id = company_id)\n  ) into result;\n  return result;\nend;\n$$;\n```\n\n`security definer` means the function runs with the privileges of its owner, usually the database owner. Without `set search_path`, a caller can hijack the path and make the function resolve objects they control. This is the classic Postgres privilege-escalation path.\n\nTurn the rule on, then write it per table. A table that belongs to a person:\n\n```\nalter table public.profiles enable row level security;\n\ncreate policy \"users read own row\" on public.profiles\n  for select to authenticated\n  using (auth.uid() = id);\n\ncreate policy \"users update own row\" on public.profiles\n  for update to authenticated\n  using (auth.uid() = id)\n  with check (auth.uid() = id);\n```\n\nA table that belongs to a company:\n\n```\ncreate policy \"owners read own company\" on public.companies\n  for select to authenticated\n  using (owner_id = auth.uid());\n```\n\nPin the search path on every elevated function:\n\n```\ncreate or replace function public.get_company_stats(company_id uuid)\nreturns jsonb\nlanguage plpgsql\nsecurity definer\nset search_path = public, pg_temp\nas $$\n...\n$$;\n```\n\nOne trap sits on the other side. If you enable RLS and write no policy at all, the table locks itself. Nobody can read it, including your signed-in users, and it usually shows up as \"the app stopped working\" after a fix. Write a policy for every table you enable, and add `insert` and `delete` if the app needs them.\n\nOne more case is worth knowing about. Tables created straight in the Supabase dashboard leave no migration in your repository, so nothing in the code records whether row-level security was turned on for them. The scanner reports those as tables your app uses that it could not review, rather than claiming they are safe. The fix is to export the schema from the Supabase SQL editor and commit it to the repository, so the access rules are visible and reviewable. Dashboards are exactly where row-level security gets forgotten, because there is no pull request where somebody would notice.\n\nOnce the policies are in, the same anonymous request that used to return your customers should come back empty, or 401, or 403. That is the live check in the scanner. It sends exactly one read request for one table, and it never signs in. It also reports what it could not check, so a table it never saw is never counted as a pass.\n\nA scanner will name the table. It will not write the policy that fits your schema, and it will not tell you which of the four operations your app actually needs. The $199 review does that. It reads your migrations, lists every table that needs a policy, and hands you the SQL for each one so you can paste it straight into your builder. 48 hours, fixed price. It is a review of your code, not a penetration test of your live site, and it is not a guarantee that your app is secure.\n\nThe scanner that finds this is open source: [github.com/rafikulislammollaq-dev/vibeaudit](https://github.com/rafikulislammollaq-dev/vibeaudit)\n\nIt also runs in the browser, where your project is read on your own machine and never uploaded: [rafikulislammollaq-dev.github.io/vibeaudit/scanner](https://rafikulislammollaq-dev.github.io/vibeaudit/scanner/)\n\nIf you would rather have a person read the findings and explain them, that is a $199 review: [rafikulislammollaq-dev.github.io/vibeaudit](https://rafikulislammollaq-dev.github.io/vibeaudit/)", "url": "https://wpnews.pro/news/supabase-rls-not-enabled-what-it-means-and-how-to-fix-it", "canonical_source": "https://dev.to/sukanyamondol/supabase-rls-not-enabled-what-it-means-and-how-to-fix-it-2lgo", "published_at": "2026-10-20 12:40:24+00:00", "updated_at": "2026-10-20 12:49:02.682085+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-products"], "entities": ["Supabase", "Lovable", "symbioticsec.ai", "Postgres"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/supabase-rls-not-enabled-what-it-means-and-how-to-fix-it", "markdown": "https://wpnews.pro/news/supabase-rls-not-enabled-what-it-means-and-how-to-fix-it.md", "text": "https://wpnews.pro/news/supabase-rls-not-enabled-what-it-means-and-how-to-fix-it.txt", "jsonld": "https://wpnews.pro/news/supabase-rls-not-enabled-what-it-means-and-how-to-fix-it.jsonld"}}