I've been building a small static checker for Supabase + Stripe apps, the kind people ship fast with Lovable, Bolt, Cursor or Claude Code. It's a personal project, MIT licensed: vibe-preflight. It runs as a plain Node script or as an Agent Skill, and it looks for six things I keep seeing in rushed apps:
- tables in
publicwith Row Level Security never turned on - policies like
using (true)that let anyone read or write - the
service_rolekey or a Stripe secret used in browser code - secrets behind
NEXT_PUBLIC_/VITE_/EXPO_PUBLIC_ - Stripe webhook handlers that never call
constructEvent -
.envfiles that git tracks
Fixtures and unit tests only tell you so much, so I pointed it at two public MIT starters to see what it does on code I didn't write.
Starter 1: vercel/nextjs-subscription-payments
vibe-preflight 1.0.0 · 56 source files, 3 SQL files · 6 checks
warning schema.sql:66
Policy "Allow public read-only access." on `public.products` (select) lets anyone (signed in or not) read every row. [rls-permissive-policy]
warning schema.sql:99
Policy "Allow public read-only access." on `public.prices` (select) lets anyone (signed in or not) read every row. [rls-permissive-policy]
warning supabase/migrations/20230530034630_init.sql:66
(same policy, migration copy)
warning supabase/migrations/20230530034630_init.sql:99
(same policy, migration copy)
0 errors, 4 warnings
That's the result I wanted. A pricing page has to show products and prices to people who aren't logged in, so a public select policy on those two tables is on purpose. That's why a read policy only gets a warning. A using (true) on insert, update or delete is an error.
Starter 2: makerkit/next-supabase-saas-kit-lite
vibe-preflight 1.0.0 · 291 source files, 2 SQL files · 6 checks
error apps/web/.env:1
`apps/web/.env` is committed to git. [env-file-committed]
error apps/web/.env.development:1
`apps/web/.env.development` is committed to git. [env-file-committed]
error apps/web/.env.test:1
`apps/web/.env.test` is committed to git. [env-file-committed]
error apps/web/config/auth.config.ts:29
`NEXT_PUBLIC_AUTH_PASSWORD` looks like a secret and is exposed to the browser. [public-env-secret]
error packages/features/auth/src/schemas/password.schema.ts:11
`NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS` looks like a secret and is exposed to the browser. [public-env-secret]
error packages/features/auth/src/schemas/password.schema.ts:12
`NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS` looks like a secret and is exposed to the browser. [public-env-secret]
error packages/features/auth/src/schemas/password.schema.ts:13
`NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE` looks like a secret and is exposed to the browser. [public-env-secret]
7 errors, 0 warnings
Seven errors looks alarming. I opened every file, and all seven were false positives.
The "password" variables are on/off switches. The code reads them like this:
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
numbers: process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS === 'true',
My rule saw PASSWORD in the name and stopped there. It never checked how the value was used.
The committed env files hold no real secrets. .env only has NEXT_PUBLIC_* settings like the site URL, theme colour and AUTH_PASSWORD=true. Those values end up in the browser bundle anyway. .env.development and .env.test do contain a SUPABASE_SERVICE_ROLE_KEY, but it's the key the Supabase CLI prints for every local stack, pointing at 127.0.0.1. If you decode the JWT, the issuer is supabase-demo. Anyone who runs supabase start has the same key, and this starter commits it on purpose so a fresh clone runs straight away.
What I changed in 1.1.0
-
Boolean flags are skipped. A secret-sounding
NEXT_PUBLIC_name passes when every value the project gives it is a boolean and every place the code reads it compares it to'true'or'false'. If one file doesconst mode = process.env.NEXT_PUBLIC_SECRET_MODE, it gets flagged again. -
Committed env files get a severity based on what's inside. If the only keys are JWTs with issuer
supabase-demoand nothing looks like a real credential (sk_live_,whsec_,sb_secret_, other JWTs…), it's a warning that says why. Same for a file that only sets public variables. Anything else stays an error. A test puts a real-format Stripe key next to a demo key and checks that it's still an error.
Same repo after the change:
warning apps/web/.env:1
`apps/web/.env` is committed to git. It only sets browser-exposed variables, which ship in the bundle anyway; keep it that way, or real keys added later get committed too. [env-file-committed]
warning apps/web/.env.development:1
`apps/web/.env.development` is committed to git. Its keys are the public Supabase local-dev defaults, which is fine; make sure real project keys never go in this file. [env-file-committed]
warning apps/web/.env.test:1
(same, for .env.test)
0 errors, 3 warnings
I kept those as warnings because the habit is still risky. Once someone pastes the production service key into .env.development "just to test something", it's in git history for good.
What I took from it
- A security check that cries wolf gets turned off. Seven fake errors on a well-maintained starter would make me add
--skipand forget about it, and then the real problem goes unnoticed too. - Name matching is only a starting point. The useful signal was in how the value is used (
=== 'true') and in what the value is (the JWT issuer). - Run your tool on code you didn't write before you trust it. My own fixtures happened to avoid both cases.
It's still a static check. A clean run doesn't mean an app is secure: storage policies, auth flows and anything created only in the Supabase dashboard are out of its reach.
If you have a public Supabase + Stripe repo where it gets something wrong, I'd like to see the output.
Disclosure: vibe-preflight is my personal project. I used AI tools (Claude Code) to help write the code and this post, and I checked the scanner output and the files above myself.
Top comments (0)