All 9 checks with why-it-matters prose, severity, and cross-references to related audits.
A Supabase table without row-level security is readable and writable by anyone who has the anon key — and the anon key ships in every client bundle by design. An attacker who opens devtools on the login page can pull the JWT, hit the REST endpoint directly, and dump or mutate the entire table. Firebase/Supabase misconfiguration incidents catalogued at appsecurity.net show a recurring pattern where RLS is either disabled outright or uses a permissive `USING (true)` policy, handing every table's contents to anyone with the anon key (which ships in the client bundle). AI coding tools routinely create tables with the Supabase CLI or dashboard without emitting the `ENABLE ROW LEVEL SECURITY` statement and without writing policies, because the default `CREATE TABLE` syntax doesn't require it. This is the single most common cause of public Supabase data leaks — the pattern has been behind dozens of disclosed incidents where customer emails, private messages, and payment records were scraped from production anon endpoints.
Why this severity: Critical because a disabled RLS policy or a `USING (true)` policy exposes every row to unauthenticated reads and writes through the public anon key shipped in every client bundle.
project-snapshot.security.supabase-rls-enabled-with-real-policiesSee full patternA Supabase RLS policy has two halves. `USING` decides which existing rows you may see or touch; `WITH CHECK` decides what a row may look like after you write it. Row-level security can be enabled on every table and still let any signed-in user write anything, because an INSERT policy with no `WITH CHECK`, or one whose check is only `auth.role() = 'authenticated'`, never ties the new row to the caller. The client picks the `user_id` (or `tenant_id`) in its own request body, so a logged-in attacker inserts rows owned by someone else, plants records in another tenant, or updates their own row to reassign it to a victim. AI-generated migrations produce this constantly: the model writes a tidy `FOR SELECT USING (auth.uid() = user_id)` policy, then adds `FOR INSERT TO authenticated WITH CHECK (true)` or omits the check because the insert 'just works' in the demo. This check covers ownership binding in write policies; policies whose predicate is literally `true` are judged by the Stack Scan RLS check and are deliberately not re-judged here.
Why this severity: High because a write policy that does not bind the written row to the caller lets any authenticated user forge or reassign rows across tenants, which is a direct broken-access-control path, but it requires a valid session and does not by itself expose reads.
supabase-rls-keys.rls-coverage.write-policies-have-with-checkSee full patternRLS filters rows, never columns. When a table is readable by `anon` or by every signed-in user (a waived `USING (true)` policy, or any read policy that is not tied to the caller), every column of every row comes back from the REST endpoint with a plain `select=*`. Supabase also grants table-level privileges on new `public` tables to `anon` and `authenticated` by default, so the only things that hide a column are a column-level grant, a separate owner-only table, or a non-exposed schema. AI-built apps routinely put a `password_hash`, `api_key`, `stripe_customer_id`, `phone`, `date_of_birth`, or `email` column on the same `profiles` or `users` table they marked public for the leaderboard, so one anonymous GET dumps every user's secrets and personal data. The Supabase database advisor flags this class as lint 0023, sensitive columns exposed. This check judges what anon and authenticated can actually read after column grants and views are taken into account.
Why this severity: Critical because a sensitive column on a publicly readable table is exfiltrated with one unauthenticated GET, exposing credentials or personal data for every row at once.
supabase-rls-keys.rls-coverage.sensitive-columns-not-api-readableSee full patternA Postgres view runs with the privileges of the role that owns it, which on Supabase is `postgres`, a role that bypasses row-level security. So a view over an RLS-protected table, readable through the API, returns every row of the underlying table to anyone who can query the view: the table's policies are never consulted. The view looks like a harmless convenience ('a leaderboard', 'a dashboard summary'), and the RLS on the base table gives a false sense of safety. PostgreSQL 15 added `security_invoker`, which makes the view evaluate with the caller's privileges and therefore enforce the underlying RLS. Supabase's advisor flags the unsafe default as lint 0010, security definer view. AI tools generate `CREATE VIEW` freely and almost never add the option, so this is a common quiet bypass of an otherwise correct RLS setup.
Why this severity: High because a definer-style view silently defeats RLS on its base tables for every API caller, but it only exposes data reachable through views that exist, which is narrower than a missing-RLS table.
supabase-rls-keys.rls-bypass-surfaces.views-use-security-invokerSee full patternEvery function in an exposed schema is a public API endpoint: Supabase serves it at `/rest/v1/rpc/<name>` to anyone holding the anon key. A `SECURITY DEFINER` function runs with its owner's privileges, which bypass RLS, so a definer function that updates a balance, grants a role, deletes a user, or returns rows is an unauthenticated admin endpoint unless EXECUTE is revoked. Postgres grants EXECUTE on new functions to `PUBLIC` by default, and Supabase's default privileges additionally grant it to `anon`, `authenticated`, and `service_role`, so merely writing the function exposes it. AI tools love definer functions ('so RLS does not get in the way') and never revoke execute. Callers pass their own arguments, so a function taking a `user_id` parameter lets any visitor act as any user. The advisor reports this as lints 0028 (anon) and 0029 (authenticated).
Why this severity: Critical because an executable SECURITY DEFINER function is an RLS-bypassing endpoint callable with the public anon key, so a single missed revoke can hand out reads or writes across every user.
supabase-rls-keys.rls-bypass-surfaces.security-definer-functions-not-public-executableSee full patternThe `auth` schema holds every user's email, phone, password hash, confirmation and recovery tokens, and raw metadata, and it is deliberately not exposed through the API. A developer who wants a 'users list' or to join profiles to emails writes `create view public.users as select id, email, raw_user_meta_data from auth.users`. If that view runs as its owner (the default) and `anon` or `authenticated` can read it, one API call returns every user's email address and metadata to anyone with the public key, and the view bypasses the lock on `auth.users` entirely. Even with `security_invoker` the pattern is wrong because the caller has no rights on `auth.users` and the view either errors or gets 'fixed' by loosening grants. AI tools reach for this shortcut constantly because the dashboard shows `auth.users` as the place users live. The advisor flags it as lint 0002.
Why this severity: Critical because one unauthenticated request can return every registered user's email and metadata, turning the identity table into a public contact list for phishing and account takeover.
supabase-rls-keys.rls-bypass-surfaces.auth-users-not-exposed-via-viewSee full patternRow-level security is not supported on materialized views or foreign tables, so neither can be protected by policies. A materialized view created in `public` is queried through the REST endpoint like any table, and by default `anon` and `authenticated` hold SELECT on it, so a cached aggregate or a snapshot of user data is readable by anyone and nothing in your RLS setup applies. A foreign table (a `postgres_fdw` or other wrapper to another database or an external API) exposes the remote data the same way, along with whatever credentials the wrapper uses. These objects feel internal ('just a reporting rollup') and AI tools place them in `public` because that is where the migration is already working. The advisor reports them as lints 0016 and 0017.
Why this severity: Medium because the exposure requires such an object to exist and to hold sensitive data, and the usual content is aggregated or derived, but when it does hold row-level data there is no policy layer to stop reads.
supabase-rls-keys.rls-bypass-surfaces.no-matview-or-foreign-table-in-apiSee full patternSupabase Auth stores two metadata blobs in the user's JWT. `app_metadata` can only be written with the service role. `user_metadata` (also `raw_user_meta_data` in the `auth.users` table) is writable by the user themselves through `supabase.auth.updateUser({ data: { role: 'admin' } })`. A policy such as `using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin')` therefore lets any signed-in user make themselves an admin with one client call, then read and write everything the policy guards, and the same applies to tenant ids and plan flags. This is the classic AI-generated role check: the model sees a role claim in the token and picks the first metadata key it finds. The advisor flags it as lint 0015. Using `app_metadata` (set only server-side), a roles table keyed by `auth.uid()`, or Supabase custom claims via an auth hook is safe.
Why this severity: Critical because the attacker needs only a normal account and one client call to forge the claim the policy trusts, granting admin or cross-tenant access to everything the policy protects.
supabase-rls-keys.policy-trust-inputs.policies-do-not-trust-user-metadataSee full patternA variable name like `NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY` is a direct contradiction in terms — the `NEXT_PUBLIC_` prefix tells the bundler to inline the value into every client bundle, and a service-role key grants RLS-bypassing database access. Shipping one to the browser gives any visitor full admin privileges against the database, including reading other users' rows, writing arbitrary data, and deleting tables. This is a documented pattern in the Supabase + Lovable ecosystem where service-role keys prefixed with `NEXT_PUBLIC_*` get inlined into every JavaScript bundle sent to browsers, where anyone can extract them via devtools. It shows up constantly in AI-generated Supabase starters because the tool sees `SUPABASE_` env vars in docs and reaches for the public prefix out of pattern-matching habit, not understanding the access-tier distinction between anon and service-role keys. The variable name alone is enough signal to flag — intent doesn't matter when the prefix dictates build-time behavior.
Why this severity: Critical because the naming pattern guarantees client-bundle exposure on every deploy, and a leaked service-role key produces immediate full database compromise accessible from any visitor's devtools.
project-snapshot.security.no-service-role-in-public-bundleSee full patternRun this audit in your AI coding tool (Claude Code, Cursor, Bolt, etc.) and submit results here for scoring and benchmarks.
Open Supabase RLS & Keys