Supabase 公開倉庫有多常留有 anon key 可讀取的資料表?
How often public Supabase repos leave a table readable by the anon key
作者逐次重播 1,000 個公開 Supabase 倉庫的遷移歷史,檢查 RLS 關閉且 anon 擁有 SELECT 權限的資料表。可重播的倉庫中,18.8% 曾出現這類資料表;最新提交可重播的倉庫中,10.9% 仍有暴露,兩項分母分別為 777 和 393。文章指出,控制台建立新資料表預設會啟用 RLS,但 SQL 遷移檔建立資料表不會自動啟用,需逐表啟用並配置應用程式所需的存取政策。
While going through some of my older projects, I found a Supabase table with row level security off. It came in with a PR I'd mostly vibe coded, and I'd never turned RLS on for it. Anyone holding the anon key could read it, and the anon key ships in the frontend bundle. I fixed it as soon as I saw it.
Then I wanted to know how common that is. So I replayed the migration history of 1,000 public Supabase repos into throwaway local databases and checked every table, commit by commit.
Short version: 18.8% of the repos I could replay had a fully readable table at some point, and 10.9% still have one at their latest commit.
Why this happens
A Supabase frontend talks to Postgres through PostgREST, using the anon key. Supabase's default privileges grant anon SELECT, INSERT, UPDATE and DELETE on new tables in public. Row level security is the only thing standing between those grants and your rows.
The dashboard's table editor turns RLS on for new tables by default. A create table in a migration file doesn't:
create table public.messages (
id bigint generated always as identity primary key,
body text not null
);
-- no "alter table public.messages enable row level security;"
-- anon can now read and write every row
So the same table is safe or open depending on whether you made it in the UI or in SQL.
What I measured
Sample. GitHub code search for .sql files under supabase/migrations/, sliced into 8 file-size bands to get past the API's 1,000-result cap. Every band hit the cap, so the frame is the first 1,000 hits in each band: 5,865 repos. I screened them in seeded random order and kept the first 1,000 that weren't forks, templates, tutorials or Supabase's own examples, and that had at least 3 migration files and 2 commits touching them.
Replay. For each repo, every commit that touched the migrations or seed.sql, capped at 30 (first, last and evenly spaced in between). Each commit was replayed into a fresh supabase/postgres container with no outbound network, using the same rules supabase db push uses. Nothing touched a live project, and no URL was visited.
Exposure. A table in public with RLS off, on which anon has table-level SELECT. That means every row and every column is readable with the public key.
Results
| Repos | Share | 95% CI | |
|---|---|---|---|
| Exposed at any sampled commit | 146 of 777 | 18.8% | 16.2 to 21.7% |
| Still exposed at the latest commit | 43 of 393 | 10.9% | 8.2 to 14.4% |
The denominators differ because not every commit replays (more on that below). 777 repos had at least one commit that replays, and 393 had a latest commit that does.
When an exposure ended, it usually wasn't a quick fix in the next commit. 67% of the ones that ended lasted more than a day and 37% more than a week. Counting exposures that are still open, the median was 47 days (Kaplan–Meier, since open ones haven't finished yet). 22 of the 75 that ended did so at the very next migration commit.

How long exposures lasted, per repo. "Still open" is the time so far at the last commit that replays. 161 exposures in 146 repos.
A few results worth a closer look
Starred repos did worse. Repos with 1 to 9 stars were exposed at their latest commit 20.3% of the time, against 8.3% for repos with no stars, and the intervals don't overlap. I wouldn't read that as cause. Starred repos tend to have longer histories, and every extra commit is another chance to add a table.
Older repos weren't safer. Repos created before 2026 were at 16.7% at their latest commit and 2026 repos at 9.5%, and that difference is within the intervals. If you wanted to pin this on AI coding tools, this data doesn't support it.
Most latest commits don't replay at all. Only 39% of repos had a latest commit that applies cleanly on a fresh database. Of the ones that fail, 69% fail because a migration refers to a table, column or function that doesn't exist yet, almost always because it was made in the dashboard. For those projects, the migration folder can't rebuild the database it's supposed to describe.
Public reads are everywhere, and that's mostly fine. 48.6% of repos had a SELECT policy for anon with using (true) on a table that does have RLS on. That's how a public profile page or a blog works. Supabase's own linter skips that pattern on purpose and only flags always-true write policies, which showed up in 9.3% of repos.
Limits
- Migrations aren't the live database. A project can turn RLS on in the dashboard without a migration. Then it looks exposed here when it isn't. The reverse happens too.
- The sample is young and small. 72% of the repos were created in 2026 and 79% have no stars. Public repos lean toward hobby, demo and portfolio projects even after filtering.
- The frame is whatever order GitHub search returns. Repos outside those 8 size bands, or past the first 1,000 hits in a band, had no chance of being picked.
- The 30-commit cap can catch an exposure's start late. I replayed 25 capped repos again with no cap (3,026 commits). No repo changed classification, and start times moved by at most about a day.
The 43
42 of the 43 repos still exposed at their latest commit got a private note, by email, GitHub's private vulnerability reporting, or an issue asking for a security contact (with no details in it). The last one has no private contact channel and doesn't look deployed, so I left it alone. The published data replaces repo names with repo-NNN and table names with salted hashes, because a distinctive table name can be searched back to its repo. As of October 15, one owner has confirmed a fix.
Checking your own project
In the dashboard, Advisors > Security flags these as "RLS disabled in public". The fix is one line per table, plus policies for whatever access the app actually needs:
alter table public.messages enable row level security;
To catch it before it merges, I built Lintel, which replays a PR's migrations and fails the check when the PR adds a table anon can read:
npx @amsozzer/lintel migrations check
There's also a playground that runs the same checks in your browser.
The method, the scripts and the anonymized data (one row per sampled commit and per exposure) are in the repo: study/results.md.
Originally published on amsozzer.com.
來源:DEV Community · dev.to