Row-level security is the lock Supabase expects you to turn on before a browser is allowed to talk to your tables. The publishable key, and the older anon key, are supposed to be in the page. Securing your data says that key is safe to expose only when row-level security is enabled and the grants are the smallest set the app needs. If you skipped the lock, the key in the JavaScript is enough for a stranger to read and write the table.
This is the plain-English version of Supabase's row-level security guide. It's for anyone who asked an AI builder for "a database" and then got a screen that works. You don't need to become a database administrator. You do need to know which two checks Postgres runs, and which sentences in a policy mean "everyone."
A policy is an extra WHERE clause
Supabase describes a policy as a rule Postgres runs every time a table is accessed. Their example is a todos table with a policy that says a signed-in person can select a row when auth.uid() equals that row's user_id. They say to think of it as Postgres adding where auth.uid() = todos.user_id to the query. You don't have to remember the syntax to use the idea. A good policy names who the row belongs to. A bad policy says the check is simply true.
The helper auth.uid() is the id of the user Supabase Auth put in the JWT. If your table stores that id in a column, the policy compares the two. If the builder named the column owner or profile_id, the comparison has to use that name. A policy that mentions a column the table doesn't have doesn't protect you. It errors, and the next prompt often "fixes" the error by deleting the check.
Grants first, then policies
Postgres runs two checks. Grants decide whether a role may run select, insert, update, or delete on the table at all. Policies decide which rows that operation applies to. Supabase tells you to set both for every table you expose.
On existing projects, the docs say a new table in the public schema starts with every privilege already granted to anon, authenticated, and service_role. The list includes select, insert, update, delete, truncate, references, and trigger. Not every project grants these automatically. The guide points at default privileges for the exceptions. If your project does grant them, adding policies later doesn't take the grants away. A table that is "protected only by policies" can still let anon insert if you never revoked that grant.
The failures look different, which helps when a builder says the query is broken. A missing grant raises Postgres error 42501 before any policy runs. A policy that matches no rows returns an empty result, not that error. When a request fails and you believe the policy should allow it, check the grant before you loosen the policy.
The procedure in the docs, for every table in an exposed schema, is short enough to follow in the SQL editor:
- Enable row-level security.
- Revoke all privileges on that table from
anonandauthenticated. - Grant back only what that role should do. A private notes table might grant select, insert, update, and delete to
authenticatedand nothing toanon. A public feed might grant select to both, and no writes. - Write one policy per operation, then test the allow and the deny.
Supabase's own sentence after the enable step is the one to remember: once row-level security is enabled, no data is accessible through the Data API with a publishable key until you create policies. The app will look empty until the policies exist. That's the lock working, so don't panic. The fix is a narrow policy. Don't reach for a policy of true, and don't paste the secret key into the client.
anon, authenticated, and "anonymous users"
Supabase maps each request to a Postgres role. anon is a request with no signed-in user. authenticated is a request with a signed-in user. You put those names in the policy's TO clause.
There's a naming trap. An anonymous user in Supabase Auth isn't the anon role. The docs say an anonymous user assumes the authenticated role and can be told apart from a permanent user by the is_anonymous claim on the JWT. If you turn on anonymous sign-in so the demo "just works," a visitor can get an authenticated JWT without proving who they are. A policy that only checks TO authenticated then treats that visitor as signed in. Read the policy with that in mind before you store anything private.
A policy written as to anon using ( true ) grants every signed-out visitor read access to every row the role can already reach through grants. Supabase says to use that only for data that is meant to be public. A product catalog can be public. A table of customer emails can't. Look for using ( true ) and with check ( true ) on any table whose name sounds private: profiles, orders, messages, files, leads.
The key that ignores the policies
API keys explain the bypass. A secret key, and the legacy service role key, run as service_role. That role has BYPASSRLS. Policies never apply to it. Supabase says to use a secret key for administrative work, and never in the browser.
Here's a footnote worth knowing so you don't "fix" the wrong thing. Supabase says a secret key bypasses row-level security only when the request doesn't also carry a user access token. If the request carries one, it runs under that user's policies, even if the client was created with a secret key. Their troubleshooting note says row-level security is enforced from the Authorization header, not from the apikey header. A browser bundle shouldn't contain the secret at all. Hiding it behind a second header isn't the design.
The publishable key stays in the browser. It can't skip the policies. If a screen is blank after you enable row-level security, the client is doing what the docs describe. Move the admin query to a server or an edge function that reads the secret from the server environment. Keep the browser on the publishable key.
Views, functions, and the exposed schema
Tables aren't the only door. Supabase says views bypass row-level security by default because Postgres creates them as security definer, usually as the postgres user. A view over a locked table can return every row the policies were meant to hide. If the builder created a view so a join would be easy, treat the view like a table: don't leave it in an exposed schema unless you have checked what it returns to anon.
The same page warns that a security definer function in an exposed schema is callable over the Data API with the creator's privileges. The docs say never to create one in a schema listed under "Exposed schemas" in the API settings. Open that setting and read the list. public is the usual exposed schema. A function the chat added there, with security definer, is a side door even when every table has row-level security on.
What to click through before you share the URL
Do this in the Supabase SQL editor or table editor, signed in as the project owner. The free check can't see this screen.
- List tables in the exposed schemas. For each one, confirm row-level security is enabled.
- List policies. Read the
USINGandWITH CHECKexpressions. Mark any that are onlytrue, and any that don't mention the signed-in user on a private table. - Check grants for
anon. It shouldn't be able to insert, update, or delete private data. It shouldn't be able to select it either, unless that table is public on purpose. - While signed out of the app, request one row with only the publishable or anon key. A real row from a private table means you aren't done.
- Repeat for views and for functions in the exposed schema.
If the app was built with Lovable, the database might be Lovable Cloud rather than a supabase.co URL you can see in the page. Lovable's docs describe that backend as Postgres, and the locks are the same idea. Use the Lovable guide and confirm the locks in Cloud if the page never shows a Supabase host. Bolt projects that picked Supabase at creation are covered in the Bolt guide. The longer checklist is at /checklist. How to tell the anon key from the secret key is the previous note, how to check if your Supabase anon key is exposed.
What the free check can and cannot say
Deploy Safely's free check looks at the public page and the scripts it loads. It doesn't open your Supabase dashboard, so it can't read policies, grants, or the exposed-schema list.
The table check runs only when the page already contains a supabase.co URL and a legacy anon JWT. It asks the public REST catalog for table names, then requests one row per table. If a row comes back to that signed-out call, the report names the table and drops the row. That's evidence the anon role could read at least one row. It isn't a full copy of the table, and it isn't a list of which policy allowed it.
If no row comes back, don't treat that as a pass. The table might be empty, the catalog might have been hidden, or the project might be using the newer sb_publishable_ key, which this check doesn't send. A service role JWT in the bundle is reported on its own, and the check doesn't use that key. The same run also requests /.env, /.git/HEAD, /.git/config, and /.npmrc, and it notes missing security headers on the main response when two or more of the watched ones are absent. None of that replaces the dashboard pass above.
A prompt you can paste
Paste this into the builder, publish again, and run the check a second time. If a secret was already on the live site, rotate it in that vendor's dashboard. The prompt cannot do that part.
Turn on Supabase row-level security without loosening it to make the screen load. 1. For every table in an exposed schema, enable row-level security. Revoke all privileges from anon and authenticated, then grant back only the operations each role needs. 2. Add policies so authenticated users read and write only their own rows, using auth.uid() compared to the owner column on that table. Use a policy of true only on tables that are public on purpose, and name those tables in your reply. 3. Do not put the service role key or an sb_secret_ key in client code, and do not prefix either one with VITE_ or NEXT_PUBLIC_. The anon key and sb_publishable_ key may stay in the browser. 4. Do not add a view or a security definer function in an exposed schema to bypass a permission error. If a view already exists there, move it or lock it down, and tell me which one. 5. List any policy you changed from true to a real check, so I can confirm the screen still shows only the signed-in user's rows. Do not disable row-level security to silence an error.