I've been using Supabase as a backend for several months. Most of that time went into errors. Something would break, and I'd end up changing my setup until it worked again. At some point I got curious about what problems other developers were hitting, so I spent a week just researching them before writing any code.
One problem stood out because it has a deadline. Supabase is replacing its legacy JWT-based anon and service_role keys with new sb_publishable_ and sb_secret_ keys. Projects created after November 2025 don't get legacy keys, and Supabase's docs say the legacy keys are planned for removal by the end of 2026. Every old tutorial, Stack Overflow answer and copied .env.example still uses the old names.
Why this isn't a find and replace
A legacy key reference can mean two very different things. A service_role key in a server-side file is a migration task. The same key reachable from client code is an emergency, because it bypasses row level security and can end up in the browser. So the tool has to classify what it finds, not just search for it.
What I built
supabase-migrate-doctor is a CLI (and an MCP server) that scans a codebase:
pip install -e .
supabase-migrate scan ./path/to/repo
It looks for legacy key literals, legacy env var names, and keys that are already migrated. Each finding gets a level:
- CRITICAL: privileged key reachable from client code
- HIGH: privileged key, server-side
- MEDIUM: anon key
- INFO: already migrated
It exits non-zero on HIGH or above, so it can gate CI.
I did not want to trust a general chatbot for the explanations. A model's training data probably treats the old key system as the correct one, so it can give confident but wrong migration advice. Instead, every explanation is grounded in a small knowledge base of Supabase's own docs, with the source URL attached. There are two modes. The default is an offline template that needs no setup. If you set a Groq or Gemini key, the explanation is written in natural language, but it can only use the retrieved doc as context. If the AI call fails, it falls back to the template.
Planning took the longest
The building was not the slow part. Working out how the tool should be structured was. I decided what the report should look like first, since that seemed the easiest part to get right, and built the scanner and classifier behind it. It took about 20 commits before it worked, and more after that to refine it.
Testing it
First, a ground-truth check. I built a small sample repo with a labeled set of expected findings:
python -m tests.eval
It reports precision 1.00 and recall 1.00. That is only 5 expected findings, so I treat it as a sanity check, not proof.
The more useful test was giving it to other people. Three people ran it on real public repos in their own Codespaces: 6 scans across 5 repos.
| Repo | Critical | High | Medium |
|---|---|---|---|
| permitio/supabase-fine-grained-authorization | 0 | 0 | 4 |
| John-Weeks-Dev/ebay-clone | 0 | 0 | 1 |
| supabase-community/nextjs-subscription-payments | 1 | 4 | 4 |
| salmandotweb/nextjs-supabase-boilerplate | 1 | 1 | 1 |
| KolbySisk/next-supabase-stripe-starter | 2 | 1 | 3 |
One real finding, from nextjs-subscription-payments:
[HIGH] utils/supabase/admin.ts:17
process.env.SUPABASE_SERVICE_ROLE_KEY || ''
The explanation says this key gives full access that bypasses row level security and should only live in server-side environments, with a link to Supabase's migration guide.
What broke when other people used it
Three things failed, and none were bugs in the scanner:
-
No module named 'supabase_migrate.cost_tracker': a Codespace had cloned the repo before I pushed that file. Fix: push the file and rungit pull. -
run_and_log.sh: No such file or directory: a pasted setup block got cut off partway. Fix: paste it in one go and check withls. -
destination path already exists: a tester re-rangit cloneinto an existing folder. Fix: reuse the folder.
I would never have seen these alone. My instructions assumed a state that only existed on my machine.
Limits
Five repos is a small sample, and all of them are Next.js projects. It doesn't cover Flutter or plain Python backends. The testers followed a script I wrote instead of exploring freely. I see this as a first real-world pass, not a validation study.
What's next
- Open a PR that renames the env var and adds a migration checklist
- Optionally probe a live project to confirm which key format is configured
- Replace the topic lookup with real embedding-based retrieval once the knowledge base grows
Top comments (0)