Supabase key checker

Two keys, identical to look at, opposite consequences.

Paste a Supabase key and find out which one you are holding. Nothing is transmitted: the key is decoded in your browser, because sending a live database credential to a website to be checked would be the exact mistake this tool exists to catch.

Runs in your browser. Nothing is sent to us.

What this checks, and what a good answer looks like

Why the two keys are impossible to tell apart by eye

Both are JSON Web Tokens. Both are the same length, start with the same characters, and are copied from adjacent rows of the same settings page. The only difference is one claim buried in the middle section: role, which reads either anon or service_role. That section is base64, not encryption, so anyone can read it. This tool just reads it for you.

service_role: bypasses every policy you wrote

This key ignores row level security entirely. Not "has broad permissions": ignores the rules. Every policy comparing a row to auth.uid() is skipped, so the holder reads, edits and deletes every row in every table as though the rules were never written. If it has ever appeared in client code, a public repository, a screenshot or a support ticket, treat it as already compromised and rotate it. Automated scrapers find credentials in public bundles within hours.

anon: public by design, and only as safe as your policies

The anon key is meant to ship to browsers, so finding it in your JavaScript is not a problem. What it is worth depends entirely on your row level security. With RLS enabled and policies that compare rows to the signed-in user, it is exactly what it should be. With RLS switched off on a table, or a policy that reads using (true), this public key reads that whole table for anyone who has loaded your site, which is everyone.

How this goes wrong in practice

Almost always the same way. Something returns an empty array or a permissions error, the quickest thing that makes it stop is the other key, and it works immediately because that key ignores the rule that was correctly stopping you. The error was the system functioning. Swapping keys does not fix an access problem, it removes the access control, and it usually ships because nothing visibly breaks afterwards.

The expiry claim

Supabase keys carry an exp. We report it because an expired key explains a class of confusing production failure, but note that expiry is not a remedy for exposure: a service_role key that leaked and later expired was still valid for the whole window in which it was public.

How to fix it

If this is an anon key, there is nothing to fix here and the work is in your policies. If it is a service_role key that has been anywhere near a browser, rotate first and investigate afterwards.

Turn RLS on and write a real policy
-- 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);
Find tables with RLS still off
-- 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';
Where each key belongs
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 -50
Rotating
1. 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.
What this tool cannot see

This reads one key you already have. It cannot tell you whether that key is in your production bundle, whether it is in your git history, or whether the tables it can reach have policies worth the name. For the first, run the exposed API key scanner against your deployed URL. For the other two, the answer is in the repository rather than in anything visible from outside.

Scan the code tooFree to start. Connect a repository and the first scan runs in about two minutes.