16,000 exposed Supabase databases: how to check your own RLS
Security firm UpGuard asked about 300,000 websites that run on Supabase for a table named users, and 16,326 of their databases answered with data. The report, published on September 25, 2026 by Greg Pollock, says over half of those databases held indicators of personal data and a smaller share held passwords or authentication tokens. Supabase’s chief information security officer, Bil Harmer, told TechCrunch that the company had not seen the research, that its projects are “secure by default” and that “customers control how their own projects are configured”.
We rebuilt the mechanism with plain Postgres and PostgREST and recorded what an unauthenticated request gets back before and after Row Level Security is switched on. We also add a date neither the report nor the coverage mentions: on October 30, 2026, Supabase changes a default on every existing project, and that change does not touch the tables that are already open. A six-item checklist for your own project comes before our read at the end.
One probe against 300,000 domains found 16,326 readable databases
UpGuard says a site that uses Supabase can be fingerprinted from its public JavaScript, where the database address and the API key names sit. Rather than scan the whole web, the researchers started from two datasets: BuiltWith, which says a site uses Supabase and then requires crawling that site for the database address, and the Chrome UX Report on BigQuery, whose raw JavaScript files can be queried for the address and the keys directly. That gave them around 300,000 unique domains.
Advertisements
They then sent each domain one request, for a table named users. The answers fell into three kinds: nothing, because a security policy blocked it or the project was gone; no users table but the name of another table the API could read, which the report calls a “hint”; or a page of rows.
The report identifies 16,326 databases exposing readable tables.
UpGuard classified the exposure by table schema rather than by reading every row. By that measure, over half of the databases showed signs of personally identifiable information, a smaller percentage included passwords or tokens, and a very small number looked like card data.
Ecommerce sites and restaurants were the most likely to hold personal data and a payment integration. The researchers write that whether a site sold to consumers or to businesses made no difference to what it exposed, and name the common thread: the sites were built by AI coding agents, and their owners did not know the configuration.
Advertisements
The report places itself after a string of smaller studies it lists:
- Modern Pentest scanned 107 Y Combinator startups and found 28% exposing personal data.
- Symbiotic Security scanned 1,072 vibe-coded apps and found 39 with tables readable with the public key.
- Escape found 175 databases leaking personal data across about 1,400 vibe-coded apps.
- Red Access scanned 380,000 URLs and found 5,000 accessible apps, 2,000 of them exposing sensitive data.
- Wiz found in February 2026 that the Supabase behind Moltbook was leaking 35,000 email addresses and 1.5 million API tokens.
Five databases UpGuard opened, from a valet CRM to a consulate
To check that the schemas pointed at real data, UpGuard opened a handful of databases whose metadata suggested a serious exposure, and says it notified the owners where it found one. The report names none of the companies. The five it describes are the ones in the table.
| Database | What was readable |
|---|---|
| Adult content platform in India | 65,467 rows in the users table, with driver’s licence, passport and PAN fields, payout accounts, and over 100,000 private messages in a second table |
| One-time-password service in the Philippines | Over 2,000 users and over 100,000 SMS messages with OTP codes, sender IDs and SIM codes, including ridesharing texts from unrelated people |
| Valet service in the US Northeast | Over 100,000 customers with phone numbers, about 43,000 with full names and emails, about 78,000 with licence plates, plus 665 staff records with push tokens |
| Consulate of an African government | 25,000 people with addresses, and a field naming their current emergency housing location |
| Immigration and relocation service for Canada | Nearly 5,000 records with names, emails, phones and dates of birth; 884 with a plain-text password |
TechCrunch adds two details from its own conversation with UpGuard: the consulate is in France, and most of the exposed datasets appear to be in the United States.
A table without RLS answers any request, and takes writes
Supabase’s Data API is PostgREST in front of Postgres, and a request with a publishable key runs as the anon role. Two things decide what that role sees: grants, which say whether the role may touch a table at all, and Row Level Security, which says which rows.
Advertisements
We ran PostgreSQL 17.11 and PostgREST 16.4 in Docker. The setup is two containers and two Postgres roles: anon is the role PostgREST runs unauthenticated requests as, and authenticator is the login it connects with:
docker network create rlsnet
docker run -d --name rls-pg --network rlsnet -e POSTGRES_PASSWORD=pgpass postgres:17-alpine
docker exec -i rls-pg psql -U postgres <<'SQL'
create role anon nologin;
create role authenticator noinherit login password 'authpass';
grant anon to authenticator;
SQL
docker run -d --name rls-rest --network rlsnet -p 3000:3000 \
-e PGRST_DB_URI="postgres://authenticator:authpass@rls-pg:5432/postgres" \
-e PGRST_DB_SCHEMAS=public -e PGRST_DB_ANON_ROLE=anon postgrest/postgrest:latestBashThen, as the postgres superuser, we created two tables by SQL with no RLS, put a few rows in, and gave anon the grants that Supabase’s Securing your API guide says an existing project applies to every new table in public: select, insert, update and delete. The last statement tells PostgREST to reload its schema cache:
create table public."user" (id serial primary key, email text, full_name text, password text);
create table public.customers (id serial primary key, email text, phone text, plate text, notes text);
insert into public."user" (email, full_name, password) values ('[email protected]', 'Owner', 'hunter2');
insert into public.customers (email, phone, plate, notes) values ('[email protected]','+1-555-0101','ABC1234','regular, tips well'), ('[email protected]','+1-555-0102','XYZ9876','asked for receipt'), ('[email protected]','+1-555-0103','JKL4567','');
grant usage on schema public to anon;
grant select, insert, update, delete on all tables in schema public to anon;
grant usage, select on all sequences in schema public to anon;
notify pgrst, 'reload schema';SQLThe first request is UpGuard’s probe, for a table that does not exist here:
curl -s http://localhost:3000/usersBashPostgREST answers with an error, and inside it the name of the closest table it can read:
{"code":"PGRST205","details":null,"hint":"Perhaps you meant the table 'public.user'","message":"Could not find the table 'public.users' in the schema cache"}JSONThe hint depends on how close the names are. In a side test, once User and user were dropped and app_users, customers and user_profiles remained, the same request came back with "hint":null. The next request follows the hint, still with no login:
Advertisements
curl -s http://localhost:3000/userBashIt returns the row, password included:
The second table answers the same way:
curl -s http://localhost:3000/customersBashEvery row and every column comes back:
[{"id":1,"email":"[email protected]","phone":"+1-555-0101","plate":"ABC1234","notes":"regular, tips well"},
{"id":2,"email":"[email protected]","phone":"+1-555-0102","plate":"XYZ9876","notes":"asked for receipt"},
{"id":3,"email":"[email protected]","phone":"+1-555-0103","plate":"JKL4567","notes":""}]JSONThe same role can write. This request adds a made-up customer; the sed at the end keeps only the status line and the JSON out of the response headers:
curl -s -i -X POST http://localhost:3000/customers -H 'Content-Type: application/json' -H 'Prefer: return=representation' -d '{"email":"[email protected]","phone":"+1-555-0199","plate":"EVIL001","notes":"payment_status: paid"}' | sed -n '1p;/^\[/p'BashThe answer is 201 Created and the stored row:
HTTP/1.1 201 Created
[{"id":4,"email":"[email protected]","phone":"+1-555-0199","plate":"EVIL001","notes":"payment_status: paid"}]PlaintextThat is the shape of the 2025 Lovable finding, where Matt Palmer inserted a record with "payment_status": "paid" into a site’s table and bypassed its Stripe integration.
One query, run as the project owner, lists the tables at risk:
select schemaname, tablename, rowsecurity from pg_tables where schemaname = 'public' order by 2;SQLThe rowsecurity column is the answer, and here it is false on both tables:
schemaname | tablename | rowsecurity
------------+-----------+-------------
public | customers | f
public | user | fPlaintextEnabling RLS on both tables, with no policy written, is one statement each:
alter table public."user" enable row level security;
alter table public.customers enable row level security;SQLAfter that, the read returns 200 OK with an empty array, and the write fails with Postgres error 42501, “new row violates row-level security policy”. The HTTP status was 401 in our session because the request carried no token. PostgREST’s error reference maps 42501 to 403 when the request is authenticated and 401 when it is not, so the code is the signal, not the status.
Advertisements
The grant is still there. This query lists what anon still holds on the table:
select grantee, string_agg(privilege_type, ', ' order by privilege_type) as privileges from information_schema.role_table_grants where table_schema = 'public' and table_name = 'customers' and grantee = 'anon' group by grantee;SQLAll four privileges are there, RLS or not:
grantee | privileges
---------+--------------------------------
anon | DELETE, INSERT, SELECT, UPDATEPlaintextRevoking the grant closes the read too:
revoke all on table public.customers from anon;SQLThe same GET now fails with 42501 and “permission denied for table customers”. The rows never left the table, as a count shows:
select count(*) from public.customers;SQLThe result:
count
-------
4PlaintextFour rows, the three we inserted and the one the API accepted; only the API path closed.
Tested with: PostgreSQL 17.11 (postgres:17-alpine) and PostgREST 16.4 (postgrest/postgrest) under Docker 29.7.2 on WSL2 (Linux 5.15), September 30, 2026. Every command above is the one we ran, in this order. Nothing here was run against a Supabase project.
Supabase changed two defaults, and the second lands on October 30
Supabase’s position, as Harmer put it to TechCrunch, is that the platform ships secure defaults and tooling and the customer configures the project. The Security Retro for 2025, which Harmer co-wrote with CEO Paul Copplestone on January 7, 2026, lists what those defaults became:
Advertisements
- RLS enabled by default on tables created in the dashboard.
- A warning label in the Table Editor on any table without it, and an email to project owners when such a table is created.
- Weekly Security Advisor emails built on the open-source Splinter linter, with the same advisor exposed through the MCP server.
- An event trigger that can enable RLS on tables created by any tool.
- Automatic revocation of a secret key found in a public GitHub repository.
The Securing your API guide states the limit of the first item: tables created in the SQL Editor or through another tool need RLS enabled explicitly. UpGuard’s report makes the same point about tables created through the API, which is how coding agents work.
The second default is about grants, and it is newer. In a changelog post on April 28, 2026, Supabase announced that new projects can be created without the automatic select, insert, update and delete grants that had made every new table in public reachable through the Data API on creation. The setting started to become the default for new projects on May 30, 2026, in a gradual rollout, and the post schedules it for all existing projects on October 30, 2026.
Once it lands, a new table needs an explicit grant before the API can see it, whatever created it. The post is equally clear about scope: “Existing tables are not affected in your project, they keep their current grants and stay reachable.”
Advertisements
The scope drew a question in the thread. On May 28, 2026, a user asked whether existing projects needed anything done before the end of October. The next day a Supabase maintainer, Lukas Bernert, answered that nothing is needed for existing tables in existing projects, that new tables created in public after October 30 need explicit grants in new and existing projects alike, and that tables which should not be on the API at all are worth revoking per table now.
The scale behind the report is Supabase’s own. In its Series F announcement on June 4, 2026, the company said it had raised $500M at a $10B pre-money valuation, that nearly 10 million developers build on it, and that more than 60% of new databases are launched by some sort of AI tool.
Six checks for your own project
Each of these comes from Supabase’s own documentation or from the session above.
- Send the probe yourself. Take the project URL and the publishable key from your front-end bundle, the way UpGuard did, and request
/rest/v1/usersand every table name you know, with the key in theapikeyheader, which is where Supabase’s API keys guide says it goes. Ahintnaming another table, or a page of rows, is the exposure. - List tables without RLS. Run the
pg_tablesquery above in the SQL Editor; anyrowsecurity = fin an exposed schema is readable and writable by any role with a grant on it. Supabase’s Row Level Security guide says to enable RLS on every table in an exposed schema. - Enable RLS before you write a policy. With RLS on and no policy, the guide says no data is accessible through the API with a publishable key. An empty array is the sign you want; then add policies per operation, and treat
to anon using (true)as public data by definition. - Revoke the grants you do not need. Adding a policy does not take a grant back, and the guide warns that a table protected only by policies still hands
anonan insert path. Grants are checked first: a missing grant fails with42501, a policy that matches nothing returns an empty result, and the API keys guide says to check the grant before you debug the policy. - Check views and functions. Views bypass RLS by default because they are created with
security definer, so a view over a protected table hands out the rows the policy was meant to withhold. Functions are not covered by RLS at all; grantexecuteonly to the roles that need them. - Look at which key is where. The publishable key is safe in a browser because it only reaches what RLS allows; the secret key bypasses every policy and belongs on a server. The legacy
anonandservice_roleJWTs are being deprecated by the end of 2026, and a secret key found in a public GitHub repository is revoked automatically.
If you opted a project into the new grant default early, or after October 30, add the grants to every new table’s migration alongside the enable row level security statement, as the changelog recommends. The PostgREST error for a missing grant carries the exact grant statement, which is also how a coding agent is expected to self-correct.
Advertisements
The count is a floor, and October 30 does not lower it
In our reading, 16,326 is a floor. The probe was a single table name against a sample of about 300,000 domains drawn from two third-party datasets, and it only counted databases that either had a readable users table or volunteered another name. A project whose only open tables have names too far from users to trigger the hint, as app_users, customers and user_profiles were in our session, is not in the count.
The October 30 change does not close any of them. The changelog scopes it to tables created after the change, and states that existing tables keep their grants. The 16,326 databases are, by definition, existing tables.
Closing them takes an owner running the RLS query, or reading the advisor email Supabase says it already sends.
The empty array is the misleading result. In our session, RLS with no policy turned the read into 200 OK with [], which looks like a locked table. The grant on it was unchanged, and the insert path only closed because RLS blocks writes too.
Advertisements
A team that stops at the empty array and later adds a permissive policy to get the app working again has reopened the table.
What to watch is whether the October 30 rollout is uneventful for apps that still create tables from migrations without grants, and how many of the 16,326 are still readable once UpGuard or someone else runs the probe again. Supabase said it would email active projects and flag affected tables in the Security Advisor before the date; the legacy anon and service_role keys are deprecated by the end of 2026, two months later. Both dates go on the calendar of this news section.
English 










