Dos claves idénticas a la vista, consecuencias opuestas.
Pega una clave de Supabase y averigua cuál tienes en la mano. No se transmite nada: la clave se decodifica en tu navegador, porque mandar una credencial viva de base de datos a una web para que la revise sería exactamente el error que esta herramienta existe para detectar.
Qué comprueba esto, y cómo es una buena respuesta
Por qué es imposible distinguir las dos claves a ojo
Ambas son JSON Web Tokens. Ambas tienen la misma longitud, empiezan con los mismos caracteres y se copian de filas contiguas de la misma pantalla de ajustes. La única diferencia es un claim enterrado en la sección central: role, que dice anon o service_role. Esa sección es base64, no cifrado, así que cualquiera puede leerla. Esta herramienta simplemente la lee por ti.
service_role: se salta cada política que escribiste
Esta clave ignora row level security por completo. No es "tiene permisos amplios": ignora las reglas. Cada política que compara una fila con auth.uid() se salta, así que quien la tenga lee, edita y borra cada fila de cada tabla como si las reglas nunca se hubieran escrito. Si alguna vez ha aparecido en código de cliente, en un repositorio público, en una captura o en un ticket de soporte, trátala como ya comprometida y rótala. Los recolectores automáticos encuentran credenciales en bundles públicos en cuestión de horas.
anon: pública por diseño, y solo tan segura como tus políticas
La clave anon está pensada para llegar a los navegadores, así que encontrarla en tu JavaScript no es un problema. Lo que vale depende por completo de tu row level security. Con RLS activado y políticas que comparan filas con el usuario que ha iniciado sesión, es exactamente lo que debería ser. Con RLS desactivado en una tabla, o con una política que dice using (true), esta clave pública lee esa tabla entera para cualquiera que haya cargado tu sitio, es decir, para todos.
Cómo sale mal esto en la práctica
Casi siempre igual. Algo devuelve un array vacío o un error de permisos, lo más rápido para que pare es la otra clave, y funciona al instante porque esa clave ignora la regla que te estaba frenando correctamente. El error era el sistema funcionando. Cambiar de clave no arregla un problema de acceso, elimina el control de acceso, y suele publicarse porque después no se rompe nada visible.
El claim de caducidad
Las claves de Supabase llevan un exp. Lo reportamos porque una clave caducada explica toda una clase de fallo confuso en producción, pero ojo: caducar no es remedio para la exposición; una clave service_role que se filtró y luego caducó siguió siendo válida durante toda la ventana en la que estuvo pública.
Cómo corregirlo
Si esta es una clave anon, no hay nada que corregir aquí y el trabajo está en tus políticas. Si es una service_role que ha pasado cerca de un navegador, rota primero e investiga después.
-- A table without this is readable by anyone holding the anon key,
-- which is everyone who has loaded your site.
alter table public.documents enable row level security;
-- Scope every row to its owner. "using" filters reads;
-- "with check" constrains writes. A policy with only "using" lets
-- someone insert rows they will not be able to see afterwards.
create policy "owners read their documents"
on public.documents for select
using (auth.uid() = user_id);
create policy "owners write their documents"
on public.documents for insert
with check (auth.uid() = user_id);-- Run this in the SQL editor. Every row it returns is a table the
-- anon key can read in full.
select tablename
from pg_tables
where schemaname = 'public'
and rowsecurity = false;
-- And this one, for the policy that looks present but grants
-- everything to everyone:
select tablename, policyname, qual
from pg_policies
where schemaname = 'public'
and qual = 'true';anon key
-> client code is fine. VITE_SUPABASE_ANON_KEY,
NEXT_PUBLIC_SUPABASE_ANON_KEY and friends are correct.
service_role key
-> server only. An edge function, a server route, or your host's
secret store. Never in a variable your bundler inlines, which
means never behind NEXT_PUBLIC_, VITE_, PUBLIC_ or REACT_APP_.
# Check what you actually shipped:
grep -rE "service_role|SERVICE_ROLE" ./dist ./build ./.next 2>/dev/null
# And check the history, because deleting a key from a file does
# not delete it from the repository:
git log -p -S 'service_role' -- . | head -501. Supabase dashboard -> Project Settings -> API -> rotate.
2. Update the secret in your host and redeploy the server side.
3. Confirm nothing still reads the old value, then revoke it.
4. Read the logs for the exposure window. A key that was public
for a week deserves a look at what it was used for, not just
a replacement.Esto lee una clave que ya tienes. No puede decirte si está en tu bundle de producción, si está en tu historial de git, ni si las tablas que alcanza tienen políticas que merezcan el nombre. Para lo primero, lanza el escáner de claves de API expuestas contra tu URL publicada. Para los otros dos, la respuesta está en el repositorio y no en nada visible desde fuera.