Replit frontends and everything the anon key can reach
Deploy a Supabase-backed frontend from Replit and you have published two artifacts: the application and its credentials. The Supabase URL and anon key sit in the served JavaScript, because client-side Supabase libraries require them to function. This is not a leak in the conventional sense — the key is designed for distribution — but it reframes a question builders sometimes skip: reachable from every visitor's browser, what exactly can that key do against your database today?
The honest answer is "everything your policies allow, and everything your missing policies neglect". This page inventories the anon key's actual powers, shows the requests that enumerate and test them from outside, and describes the bounding work. The framing is structural rather than accusatory: hosting platforms deliver bundles; bundles contain client keys; policies are the mechanism that decides whether that arrangement is safe. When the policies lag, the arrangement is not safe yet.
Rules that check this
- criticalTable readable with the anon key
ANON_TABLE_READABLE
The key in the bundle
View-source on your deployed frontend and search for the project URL; the anon key sits beside it. Any obfuscation is cosmetic — build minification renames variables, not string constants, and the library initialises from those constants at runtime. Assume the pair is permanently public, because copies escape: browser caches, shared screenshots, anyone who ran the app once.
Treating the key as secret leads to wasted effort in the wrong direction — rotating it buys nothing while it remains required by every client, and hiding it from view-source is not achievable while it remains required by every client. The productive reframe is acceptance: the anon key is the identity "unauthenticated caller", published by design, and the entire question becomes what that identity may read and write.
Enumerating the surface
PostgREST answers a polite question with unusual candour: a GET on the API root returns an OpenAPI description of every table and view the exposed schemas offer. One request, from outside, no credentials beyond the public key. The response reflects what the project exposes right now rather than what the frontend currently uses — which is precisely the difference worth measuring:
Expect the enumeration to include tables you had forgotten — scaffolding from early experiments, tables the current frontend abandoned months ago. The listing shows surface area whether or not it is protected, which is why the second request matters more than the first: enumeration says what could be asked, and the read attempt says what actually answers.
curl -s "$SUPABASE_URL/rest/v1/" \ -H "apikey: $ANON_KEY" | head -c 600 # then, for any table the spec reveals: curl -s "$SUPABASE_URL/rest/v1/messages?select=*&limit=5" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY"
Reading what came back
The enumeration lists surface area; the per-table request tests it. Rows mean the table is readable by the world. A permission error means policies engaged. The empty array means one of two things — filtered by policy, or genuinely empty — and the difference is invisible from outside, which is why honest reports mark empty-but-reachable as unresolved rather than clean. Silence today proves little about a table that fills next month.
Write access deserves equal attention even though no honest test performs it: an INSERT policy without WITH CHECK lets callers create rows attributed to others, and an UPDATE policy falling back to its USING clause can permit more than intended. Reads prove exposure cheaply; writes require reading the policy definitions themselves, which is catalog work rather than HTTP work.
Bounding the key properly
Bounding starts with enablement everywhere and specificity thereafter: RLS on each public-schema table, forced against the owner, with per-command policies scoped to ownership columns. The wrapped form of auth.uid() matters on busy tables — evaluated once per statement rather than per row, identical semantics, materially cheaper plans:
Grant writes deliberately rather than by inheritance: an INSERT policy without WITH CHECK accepts rows attributed to anyone, and an UPDATE policy leans on its USING clause when no WITH CHECK exists, which is usually right and occasionally very wrong. Decide read and write separately, per table, and let nothing ship on defaults alone.
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY; ALTER TABLE public.messages FORCE ROW LEVEL SECURITY; CREATE POLICY "messages_participant_read" ON public.messages FOR SELECT TO authenticated USING ((SELECT auth.uid()) = ANY (participant_ids)); CREATE POLICY "messages_author_insert" ON public.messages FOR INSERT TO authenticated WITH CHECK ((SELECT auth.uid()) = sender_id);
Then keep the boundary watched
Policies decay under iteration — every feature that touches the schema is a chance to add a table ahead of its policies. RowShield automates the outside-in half continuously: the probe fetches your deployed app, extracts the Supabase credentials exactly as a visitor would, enumerates the exposed surface and attempts reads with GET requests only, reporting findings by column name and count while retaining no row values. Connecting the project adds the catalog audit for the states a read-only probe cannot see.
Findings raise alerts on transitions — created, regressed, resolved — rather than repeating hourly, and remediation SQL is generated from your real columns. RowShield is an independent product, unaffiliated with and not endorsed by Supabase; it reads pg_catalog and bucket metadata only, and its probe refuses private network addresses before connecting, re-checked on each redirect hop.
Frequently asked
- Is having the anon key in my frontend a breach?
- No — it is the designed architecture of every Supabase client application, on Replit or anywhere else. A breach requires data crossing the boundary the policies were meant to hold, which is why the meaningful test is behavioural: what the key can fetch today, checked per table, not where the key happens to live.
- Can I restrict the API to my own domain instead?
- Browser-side restrictions cannot authenticate a request: CORS governs which origins may call from a browser, not who crafted the call, and referrer checks are trivially omitted. Anyone can replay requests from a terminal with your public key. Server-side enforcement through Row Level Security is the layer that cannot be skipped around.
- Does signing users in change what the anon key can reach?
- Authentication upgrades individual callers to the authenticated role with a user id attached, which policies then use for scoping — it does not remove the anonymous surface. Everything granted to anon remains granted to anyone not signed in, so anon-facing policies deserve the same design attention as the rest.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit