anon key 可公開,service_role key 不可:AI 工具生成的 Supabase 應用程式 RLS 檢查清單
The anon key is public, the service_role key isn't: an RLS checklist for AI-built Supabase apps
VibeSheriff 整理了一份 AI 工具生成的 Supabase 應用程式 RLS 檢查清單,説明 anon key 可放在前端,但 service_role key 不可進入瀏覽器。
AI builders like Lovable, Bolt, Cursor and v0 can wire a Supabase backend into your app in minutes. That's great, but the same few misunderstandings keep showing up in the apps they generate. None of them are exotic. They're the kind of thing that's easy to miss when the app "just works".
Here's the checklist I run through on a Supabase app before calling it done.
1. The anon key being public is fine. RLS is what protects you.
The publishable key (sb_publishable_..., or the legacy anon key) is designed to live in your frontend. Anyone who opens dev tools can see it, and that's expected. What actually protects your data is Row Level Security (RLS).
Find tables in the public schema that don't have RLS turned on:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
Anything with rowsecurity = false can be read, changed and deleted by anyone holding the public key, as long as the table's grants allow it. On older projects they do by default: every new table in public is granted to anon and authenticated automatically. Newer projects (and new tables in existing projects from 30 Oct 2026) no longer get those automatic grants, but when an AI tool hits a permission denied error, its fix is often grant all on ... to anon, which puts you right back. See who can reach what:
select table_name, grantee, string_agg(privilege_type, ', ') as privileges
from information_schema.role_table_grants
where table_schema = 'public' and grantee in ('anon', 'authenticated')
group by table_name, grantee
order by table_name;
The Security Advisor in the Supabase dashboard flags tables with RLS off too.
Tables you create in the Table Editor get RLS switched on by default, but tables created with raw SQL (which is what AI tools often generate) don't. Turn it on explicitly:
alter table public.todos enable row level security;
Careful on a live app: RLS with no policies blocks everyone using the public key, including your logged-in users. Reads come back empty and writes fail until you add policies. Add the policies from section 2 at the same time (or test on a branch or staging project first). Server code using the secret key isn't affected.
2. "RLS enabled" isn't the same as "RLS correct"
Once RLS is on, read every policy:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename;
Red flags:
-
qualshowingtrueon aSELECTpolicy (that'susing (true)) means everyone can read every row. -
rolesshowing{anon}or{public}on a table that holds user data.{public}is what you get when the policy has noto ...clause, and it includes logged-out visitors. -
with_checkshowingtrueon anINSERTorUPDATEpolicy, or anINSERTpolicy that never comparesuser_idtoauth.uid(), so a user can write rows in someone else's name.
A typical "users only see their own rows" setup looks like this (it assumes a user_id uuid column holding the owner's auth user id):
create policy "Read own todos"
on public.todos for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Insert own todos"
on public.todos for insert
to authenticated
with check ((select auth.uid()) = user_id);
3. Test it like an outsider (on your own project only)
The dashboard runs queries as a privileged role, so it won't show you what a stranger sees. Ask the REST API directly with only the anon key:
curl "https://<project-ref>.supabase.co/rest/v1/<table>?select=*&limit=1" \
-H "apikey: <publishable or anon key>"
It's a read-only GET, so it can't change anything. If rows come back that shouldn't be public, your policies need work. For a private table, an empty array [] (or a permission denied error) is what you want.
One catch: when RLS blocks a read you don't get a 401. You get 200 with [], which is exactly what an empty table returns too. So test against a table that actually has data in it.
Also worth knowing: table names aren't secret. Supabase stopped serving the API's OpenAPI description to the public key in 2026, but your names are still sitting in your JavaScript (.from('todos')), so don't rely on nobody guessing them.
4. The service_role key must never reach the browser
The service_role key (or the newer sb_secret_... key) bypasses RLS completely. If it ends up in your frontend bundle, none of the policies above matter.
You can't just grep your build for the word "service_role", because the legacy key is a JWT and its payload is base64-encoded. Decode the JWTs in your build output instead. This prints only the decoded middle part of each token, never the signature:
npm run build
# Vite: dist/ Next.js: .next/static/ Create React App: build/
grep -rhoE 'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+' dist/ | sort -u |
node -e 'require("fs").readFileSync(0, "utf8").split("\n").filter(Boolean)
.forEach(t => console.log(Buffer.from(t.split(".")[1], "base64url").toString()))'
(It uses Node rather than base64 -d because JWTs drop the base64 padding, and some base64 versions choke on that or cut off the end.)
Every payload should say "role":"anon". If you see "role":"service_role", treat the key as leaked. Remove it from the frontend first, then create a new secret key under Settings > API Keys, switch your server code to it, and deactivate the legacy keys once nothing uses them. Note that deactivating the legacy keys switches off the anon key too, so move your frontend to the publishable key before you flip that switch. Whatever needed the service_role key in the browser belongs in an Edge Function or your own server.
The usual cause is an env var with a frontend prefix. Anything named VITE_..., NEXT_PUBLIC_... or EXPO_PUBLIC_... gets compiled into the client. If you're on the new key format, grep -rl 'sb_secret_' dist/ lists any build files that contain a secret key, without printing it.
5. Views and storage have their own rules
-
Views run with the permissions of the role that created them (usually
postgres), so by default they ignore RLS on the tables underneath. On Postgres 15 and later, create them withwith (security_invoker = true)so the caller's policies apply, or fix an existing one withalter view public.my_view set (security_invoker = true);. -
Storage buckets marked public let anyone with a file's URL download it. Uploads and deletes still go through policies, but downloads don't. Keep user uploads in private buckets and add policies on
storage.objects.
6. Check the rest of the deployment too
While you're at it:
-
/.envand/.git/configshouldn't be publicly reachable. Watch out for SPA hosts that returnindex.htmlwith a200for every path; look at the response body, not just the status code. - Look for other secrets in the bundle:
sk_live_,sk_test_andrk_live_(Stripe),sk-proj-andsk-svcacct-(OpenAI),sk-ant-(Anthropic),AKIA(AWS). Older OpenAI keys start with plainsk-, but that also matches plenty of harmless strings, so check each hit by eye. Stripepk_keys are public by design. - Wildcard CORS (
Access-Control-Allow-Origin: *) on your own APIs that return user data deserves a second look.
7. Generated policies need a human read
AI tools will happily write a policy that makes a permissions error go away. If a policy was added to fix an error, it's the first one to re-read. using (true) is a very effective way to make an error disappear.
Disclosure: I built VibeSheriff, which runs step 3 for you against a live site you've verified you own. It finds your Supabase URL and public key in the page and its JavaScript, picks up the table names your app's JavaScript queries, and tries to read one row from each with only that key. A table is flagged only if a row actually comes back, and only table names are reported. It's read-only. The free scan shows your score and top 3 issues. Everything above works without it, though, and I'm happy to answer RLS questions in the comments either way. 🤠
來源:DEV Community · dev.to