{"slug": "five-supabase-rls-mistakes-a-read-only-query-can-catch", "title": "Five Supabase RLS Mistakes a Read-Only Query Can Catch", "summary": "A developer documented five common Supabase Row Level Security misconfigurations — including tables with RLS disabled, policies using `using (true)`, role checks against user-editable `user_metadata`, anon-executable `SECURITY DEFINER` functions, and views without `security_invoker` — that can be detected with read-only queries against Postgres's catalog. The writeup ties the patterns to CVE-2025-48757, a class of bug found across many AI-generated apps where the public `anon` key could read other users' rows, and provides the SQL to find and fix each one.", "body_md": "Supabase gives every app a public `anon` key, and that key can talk to your database through the REST API. Row Level Security is the only thing standing between that key and your tables. When an app gets built fast, sometimes with an AI coding tool writing the migrations, RLS is often on but written in a way that still lets one user read another user's rows. CVE-2025-48757 was exactly this class of bug, across a large number of generated apps.\n\nMost of these mistakes are visible in Postgres's own catalog. You can find them with a `SELECT`, without touching a single row of user data. Here are five, with the query that spots each one and the fix.\n\n```\nselect n.nspname as schema, c.relname as table_name\nfrom pg_class c\njoin pg_namespace n on n.oid = c.relnamespace\nwhere n.nspname = 'public'\n  and c.relkind in ('r', 'p')\n  and not c.relrowsecurity;\n```\n\nAny row this returns is a table the `anon` key can read and write in full, as long as the role has the usual grants. Fix:\n\n```\nalter table public.profiles enable row level security;\n```\n\nThen add policies. With RLS on and no policies, the table returns nothing, which is safe but breaks the app, so people sometimes \"fix\" it with the next mistake.\n\n`true`\n\n```\ncreate policy \"anyone can read\" on public.profiles\n  for select using (true);\n```\n\nThis turns RLS back into \"everyone sees everything\". Find them:\n\n```\nselect schemaname, tablename, policyname, cmd\nfrom pg_policies\nwhere schemaname = 'public'\n  and (qual = 'true' or with_check = 'true');\n```\n\nSometimes this is intentional (a public list of blog posts). On `profiles`, `orders` or anything with personal data it usually isn't. The fix ties the row to the caller:\n\n```\ncreate policy \"read own profile\" on public.profiles\n  for select using ((select auth.uid()) = user_id);\n```\n\n`user_metadata` for roles\nThis one looks reasonable and is not:\n\n```\ncreate policy \"admins read all\" on public.orders\n  for select using (\n    (auth.jwt() -> 'user_metadata' ->> 'role') = 'admin'\n  );\n```\n\n`user_metadata` is editable by the signed-in user. Any user can call `supabase.auth.updateUser({ data: { role: 'admin' } })`, refresh their session, and the policy now lets them read every order. Supabase's docs call this out, but it still shows up because the field name sounds authoritative.\n\nFind policies that read it:\n\n```\nselect schemaname, tablename, policyname\nfrom pg_policies\nwhere schemaname = 'public'\n  and (qual ilike '%user_metadata%' or with_check ilike '%user_metadata%');\n```\n\nFix: keep roles in `app_metadata` (only the service role can write it) or, better, in your own table such as `memberships(user_id, org_id, role)` that users cannot insert into themselves.\n\n`SECURITY DEFINER` functions the anon key can call\nFunctions in `public` are exposed at `/rest/v1/rpc/<name>`. A `SECURITY DEFINER` function runs with its owner's privileges, which usually means RLS doesn't apply inside it. If `anon` can execute it, anyone on the internet can.\n\n```\nselect p.proname as function_name\nfrom pg_proc p\njoin pg_namespace n on n.oid = p.pronamespace\nwhere n.nspname = 'public'\n  and p.prosecdef\n  and has_function_privilege('anon', p.oid, 'execute');\n```\n\nPostgres grants `EXECUTE` to `PUBLIC` by default, so this list is often longer than people expect. Fix the grant, and pin the `search_path` while you're there so the function can't be tricked into resolving a different object:\n\n```\nrevoke execute on function public.get_report() from anon, public;\nalter function public.get_report() set search_path = '';\n```\n\nIf it must stay `SECURITY DEFINER`, move it to a schema the API doesn't expose and check the caller inside the function.\n\nA normal view runs with the privileges of whoever created it, so it can return rows the caller's policies would hide. On Postgres 15+ you can make it respect the caller instead:\n\n```\nalter view public.order_summary set (security_invoker = true);\n```\n\nFind views that haven't been switched:\n\n```\nselect c.relname as view_name\nfrom pg_class c\njoin pg_namespace n on n.oid = c.relnamespace\nwhere n.nspname = 'public'\n  and c.relkind = 'v'\n  and not (coalesce(c.reloptions, '{}')\n           && array['security_invoker=true', 'security_invoker=on']);\n```\n\nThe worst version of this is a view over `auth.users` that `anon` can select from: that is your user list, emails included.\n\n`auth.uid()` per row\nNot a leak, but common: `using (auth.uid() = user_id)` calls `auth.uid()` once per row. Writing `using ((select auth.uid()) = user_id)` lets Postgres evaluate it once per query. On large tables the difference is noticeable.\n\nI put these checks, plus a few more (public Storage buckets, extensions in `public`), into one read-only query and a small CLI around it: [supabase-rls-check](https://github.com/Tthanh2k6/supabase-rls-check).\n\n```\npip install supabase-rls-check\nsupabase-rls-check sql --print-sql > check.sql\n```\n\nYou paste `check.sql` into the Supabase SQL Editor, export the result as CSV, and the CLI turns it into an HTML/Markdown report plus a `fix.sql` draft. The draft is wrapped in `BEGIN; ... ROLLBACK;`, so nothing changes until you read it and edit that last line. It also runs as a GitHub Action that fails a pull request when a migration introduces one of these findings, and as an MCP server for coding agents.\n\nTwo honest caveats. Supabase's built-in Security Advisor already covers several of these, and you should use it. And none of this proves isolation: a catalog query can't tell you that user A can't read user B's rows through some combination of policies. For that you need tests that sign in as two real users and try. I wrote up that pattern, with 19 such tests, in [supabase-multitenant-rls](https://github.com/Tthanh2k6/supabase-multitenant-rls).\n\nIf you've seen an RLS mistake that isn't on this list, I'd like to hear it in the comments. It might become the next rule.", "url": "https://wpnews.pro/news/five-supabase-rls-mistakes-a-read-only-query-can-catch", "canonical_source": "https://dev.to/tthanh2k6/five-supabase-rls-mistakes-a-read-only-query-can-catch-100p", "published_at": "2026-11-10 06:17:41+00:00", "updated_at": "2026-11-10 06:25:28.162081+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-safety"], "entities": ["Supabase", "Postgres", "CVE-2025-48757"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/five-supabase-rls-mistakes-a-read-only-query-can-catch", "markdown": "https://wpnews.pro/news/five-supabase-rls-mistakes-a-read-only-query-can-catch.md", "text": "https://wpnews.pro/news/five-supabase-rls-mistakes-a-read-only-query-can-catch.txt", "jsonld": "https://wpnews.pro/news/five-supabase-rls-mistakes-a-read-only-query-can-catch.jsonld"}}