You opened the published app, viewed the page source, and found a long Supabase key. That feels like a leak, and it's a fair thing to worry about. For the anon key, it usually isn't. Supabase's API keys guide says the legacy anon key is the old form of the publishable key, and the publishable key is safe to put in a web page, a mobile app, or source code. The browser is supposed to have it. The lock on your data is row-level security, together with the table grants. Keeping that key secret was never the lock.
The key that must not be in the page is the secret one. Supabase's same guide says secret keys (sb_secret_...) and the legacy service_role key provide full access and bypass row-level security. Securing your data puts it plainly: the publishable key is safe to expose when row-level security is on and grants are tight, and the secret and service role keys are never safe to expose. Below, I'll walk you through how to tell those keys apart on a live URL, and how to check the lock they depend on.
What the docs mean by each key
Supabase currently documents four key types. The publishable key starts with sb_publishable_. It's low privilege. Anyone who can see your JavaScript can read it, so it only reaches what row-level security allows. The secret key starts with sb_secret_. It's for servers, Edge Functions, and other code you control. The anon key is a long-lived JWT, and the docs call it the legacy version of the publishable key. The service role key is a long-lived JWT, and the docs call it the legacy version of the secret key. Supabase says it's deprecating the anon and service role keys by the end of 2026, and points people at the migration guide.
A publishable key doesn't mean the visitor is anonymous. The API keys guide says the app sends the publishable key, and a signed-in user sends their own JWT from Supabase Auth. With no signed-in user, the request runs as the Postgres role anon. With a signed-in user, it runs as authenticated. A secret key runs as service_role, which has the BYPASSRLS attribute, so policies don't apply to it. Write policies for anon and authenticated. Don't try to "add the service role to a policy" and expect that to contain a secret key.
Supabase also says a new secret key returns HTTP 401 when it's used in a browser, because the platform matches on the User-Agent header. That's a helpful extra check on the new key format, but it doesn't give you permission to ship the key. The docs still say a secret key bypasses every row-level security policy and must never go in a browser, a shipped application, or source control. The legacy service role JWT doesn't get that browser check described in the same paragraph. If that JWT is in the bundle, anyone who loads the page can copy it.
How to look at the live page
Open the published URL, not the builder preview that asks you to sign in. Use the address a stranger would type. Then view the page source and search. You're looking for a supabase.co host, a string that starts with eyJ (a JWT), the words service_role, and the prefixes sb_publishable_ and sb_secret_.
A JWT has three segments separated by dots. The middle segment is base64-encoded JSON. Decode it on your own machine. In the browser console, on a page you trust, you can split the token and decode the middle segment. You want the role claim. anon is the public key. service_role is the key that skips the locks. authenticated is a user session, and it should be created when someone signs in, not pasted into the source. Don't paste a live key into a random website to "check" it. Copying the key to a third party is how a secret spreads.
Then open developer tools, pick a JavaScript file from your own domain, and search again. Builders often put the client key in an environment variable that the bundler inlines. Vite's env guide says variables prefixed with VITE_ are exposed in client source and shouldn't hold sensitive information. Next.js says a NEXT_PUBLIC_ variable is inlined into the JavaScript sent to the browser, and that other environment variables stay on the server. VITE_SUPABASE_URL and the anon or publishable key are the usual public pair. VITE_SUPABASE_SERVICE_ROLE_KEY or NEXT_PUBLIC_ in front of a secret is a leak, because the prefix is what publishes the value.
Also search the project, not only the live page, for sb_secret_ and for a variable named like SUPABASE_SERVICE_ROLE_KEY that is imported by a client component. A key that never made it into the published bundle is still a problem if the chat history or a public repository contains it. The live page is the part a visitor can see without asking you. The repository is the second place to look.
Why row-level security is the lock
Supabase's row-level security guide says a table in an exposed schema without row-level security is readable and writable by any role that has a grant on it. On existing projects, a new table in public starts with every privilege granted to anon, authenticated, and service_role. Those privileges include select, insert, update, and delete. Adding a policy doesn't take the grants back. A policy of using ( true ) for the anon role lets every signed-out visitor read every row the role can already reach. Supabase says to use that only for data that is meant to be public.
The same guide says that once row-level security is enabled, no data is accessible through the Data API with a publishable key until you create policies. That's the useful default: turn the lock on, then add the narrow policies you actually want. A missing grant fails with Postgres error 42501 before any policy runs. A policy that matches no rows returns an empty result. Those two failures look different, which is why a query that "returns nothing" isn't, by itself, proof of a lock. The table might be empty.
Views need a separate look. Supabase says views bypass row-level security by default because Postgres creates them as security definer. A view over a protected table can hand out rows the policies were meant to withhold. A security definer function in an exposed schema is callable over the Data API with the creator's privileges. If the builder added a view or a function "so the screen would load," open it in the SQL editor and see which schema it lives in.
The hand check in the dashboard is pretty short. For every table in an exposed schema, confirm row-level security is enabled. Read each policy. Confirm anon doesn't have insert, update, or delete unless a stranger is supposed to write. Confirm private rows are tied to auth.uid(), or to a tighter rule you can explain. Then, while signed out, request one row from the table through the API using only the publishable or anon key. A real customer row means the lock is open. If the only thing you found is the anon key itself, you don't need to rotate anything yet. Rotate immediately if you found a service role key, a secret key, or a database URL with a password.
What a free Deploy Safely check can see
The free check fetches the public page and the scripts that page loads. It doesn't log in, open the Supabase dashboard, or use any secret it finds.
If the page or those scripts contain a JWT whose role claim is anon, and the token looks like a Supabase key, the report calls that out as expected. The title in the report is that your Supabase public key is in the page, and that this part is normal. It shows up as an info note, and it isn't counted as a leaked-secret finding. If the role claim is service_role, the report treats that as critical. The check doesn't use the key, and it doesn't save the token.
The table check runs only when both of these are already in the page or its scripts: a supabase.co project URL, and a legacy anon JWT. The check then reads the public REST catalog and asks for one row from each table it lists, up to a short cap. If a table returns a row to that signed-out request, the report names the table. It doesn't store or show the row. If the API doesn't return a table list, the report says the project was seen and the tables could not be listed. An empty answer isn't proof of a lock. A lock can hide rows, or the table can simply be empty.
Two limits matter if your project has already moved to the new keys. The table check looks for the legacy anon JWT. A page that only contains sb_publishable_ doesn't give the check a JWT to send, so the table check doesn't run. Confirm the locks in the Supabase dashboard anyway. The check also looks for a JWT whose role is service_role. It doesn't have a separate rule for the sb_secret_ string. Search the bundle for that prefix yourself.
The same pass requests a short list of well-known paths (/.env, /.env.local, /.env.production, /.git/config, /.git/HEAD, and /.npmrc) and notes missing browser safety headers on the main response when two or more of the watched headers are absent. It doesn't guess other paths, and it doesn't try passwords. A quiet report means this public look didn't see a row or a service role JWT. Please don't read it as a certificate that the project is safe. The checklist has the dashboard steps. If the app was built in Lovable or Bolt, the Lovable guide and the Bolt guide cover the same keys in those builders. The plain-English version of the policies is Supabase row-level security for vibe coders.
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.
Review this project for Supabase keys that do not belong in the browser. 1. Search client code and any variable prefixed with VITE_ or NEXT_PUBLIC_ for a service_role JWT, a key that starts with sb_secret_, and a database URL that contains a password. Remove them from client code. The anon key and the sb_publishable_ key may stay in the browser. 2. Keep the secret key and the service role key only in server code or an edge function. Read them from server environment variables. 3. Enable row-level security on every table in an exposed schema. Revoke extra grants from anon and authenticated, then grant back only the operations each role needs. Add policies so a signed-in user reaches only their own rows, unless the table is meant to be public. Do not use a policy of true to silence an error. 4. Do not add a view or a security definer function in an exposed schema to get around a permission error. 5. List any secret that was already in client code so I can rotate it in the Supabase dashboard. Do not print secret values in your reply. Do not weaken a policy so the screen will load.