The Policy Nobody Wrote: Reading a Supabase Table Without Row Level Security
The challenge
A profile page asks Supabase for two columns. Ask for every column of every row instead. Bring back the admin's API key.
What you'll learn
- Read a PostgREST URL: table, select list, filters
- Explain why the Supabase anon key is public and what actually protects the data
- Widen a query from two columns of one row to the whole table
- Tell a user-chosen display name from a server-side role
- Lock a Supabase table down with RLS, a public view and column grants
Skills tested
Prerequisites
- What a query string is
- What a database table and its columns are
How it works
Supabase puts a REST API (PostgREST) in front of Postgres. The browser talks to it directly: the URL names the table (/rest/v1/profiles), the columns (select=display_name,avatar_url) and the rows (id=eq.<uuid>). Every request carries the project's anon key, a JWT with "role":"anon" that ships in the app's JavaScript.
That key is public by design. What protects the data is Row Level Security: policies written in Postgres that decide, per role, which rows a query may see. With RLS disabled on a table the anon role can select, the URL is the only limit, and the browser writes the URL. The page asking for two columns of one row is a convention, not a control.
In 2025, researchers found that apps generated with Lovable, an AI app builder, often shipped tables with RLS off (CVE-2025-48757): anyone with the public key could list users, emails and stored API keys.
Common mistakes
- Stopping at select=*. With the
id=eq.filter still there you get every column of Sam's row only, and Sam is a member. Delete the filter to see the other rows. - Taking the row called Admin.
display_nameis whatever a user typed at sign-up. That row has"role":"member"; the admin is the row with"role":"admin". - Answering Sam's key. It is the first key you see, but it belongs to the profile on screen.
- Forging a service_role token. Editing the JWT breaks its HS256 signature, and PostgREST answers
PGRST301. No forgery is needed: the anon key already reads everything. - Removing the apikey header. Supabase's gateway refuses the request before PostgREST sees it.
How to defend against it
- Enable RLS on every table in an exposed schema:
alter table profiles enable row level security;Supabase's Security Advisor flags tables where it is off. - Write explicit policies, for example owners read their own row:
create policy "own row" on profiles for select to authenticated using (auth.uid() = id); - Serve public fields through a view or a function that returns only
display_nameandavatar_url, or revoke column privileges from anon. - Keep secrets out of browser-readable tables: store API keys hashed, in a schema the Data API does not expose, and rotate any key that was ever readable.
- Never decide privilege from user-editable fields such as a display name.