Notes
Pre-launch security checklist for vibe-coded Vercel + Supabase apps
8 Oct 2026 · 1246 words
A last-pass checklist before you share a Next.js preview: client bundles, RLS, Storage, and Auth. Mechanisms you can test with curl, not a score you have to trust.
Most of the holes in a weekend Next.js + Supabase app are not clever. The publishable key is in the browser (good), RLS is off on a scaffold table (bad), a bucket is public because the <img> tag was easier that way, and Confirm email is still unchecked from the first local user.
This is the pass I run before a preview URL leaves my machine. It ties together the four longer notes — RLS, client bundles, Storage, Auth — into an order you can finish without opening five dashboards at once.
No percentages, no "most startups" claims. If a check is in here, it is because the mechanism is real and you can see it with curl or rg.
0. Know which keys you have
In the Supabase Dashboard, Settings → API Keys (not the old Settings → API page):
- Publishable key (
sb_publishable_…) or legacyanonJWT — this belongs inNEXT_PUBLIC_*. - Secret key (
sb_secret_…) or legacyservice_roleJWT — this belongs in server-only env. No prefix.
.env.local and Vercel env should look like:
bash
NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_… SUPABASE_URL=https://YOUR_PROJECT.supabase.co SUPABASE_SECRET_KEY=sb_secret_…
Two clients, two files. createBrowserClient() uses the publishable pair. createAdminClient() uses the secret pair and starts with import "server-only". If any page with "use client" imports the admin module, stop and split the files before you do anything else.
1. Search the build, not just the repo
bash
npm run build rg -n "sb_secret_|service_role|sk_live_|sk-proj-|BEGIN .*PRIVATE KEY|AKIA" \ .next/static public app src lib
A publishable key or anon JWT in .next/static is expected. A secret key, a Stripe live secret, an OpenAI key, or a PEM block is a launch blocker. Rotate after you remove the import — the bundle post has the official order (fix the leak, deploy, mint a new secret, delete the old one, search for the old prefix).
If you cannot build, download the JS from the preview URL and search that. That is what a paste-a-URL scanner does.
2. Prove what the publishable key can read
bash
SUPABASE_URL="https://YOUR_PROJECT.supabase.co" ANON_KEY="paste-the-publishable-or-anon-key" curl -sS -D - \ "$SUPABASE_URL/rest/v1/" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY" \ -H "Accept: application/openapi+json"
Write down every path that is not a feature you intended to be public. Then GET the dangerous ones:
bash
for table in profiles users orders invoices messages; do
echo "=== $table ==="
curl -sS -D - -o /tmp/rest-body.json \
"$SUPABASE_URL/rest/v1/$table?select=*&limit=1" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
-H "Prefer: count=exact"
echo
done200 plus rows, or a Content-Range that is not */0, on a table that should be private: enable RLS, revoke anon, grant only authenticated, write to authenticated policies with (select auth.uid()). Empty [] is not proof of safety. Check the Dashboard → table → RLS badge.
Views over those tables can bypass RLS if they were created as security definer. If you added a view so the client "doesn't need a policy," read it again.
3. Storage: public flag and list, separately
bash
curl -sS "$SUPABASE_URL/storage/v1/bucket" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY"
curl -sS -D - \
"$SUPABASE_URL/storage/v1/object/list/avatars" \
-H "apikey: $ANON_KEY" \
-H "Authorization: Bearer $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"prefix":"","limit":20}'Disable Public on any bucket that is not a real CDN. Replace using (true) select policies. Store user files under auth.uid() folders. Use signed URLs from a server function for anything you would not email to a stranger.
A public avatar bucket plus a later "just put the PDF in avatars" commit is how private files get a stable public URL.
4. Auth flags that should match the product
bash
curl -sS "$SUPABASE_URL/auth/v1/settings" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY"
Match the booleans to what you believe you shipped:
- Invite-only or internal → Allow new users to sign up off (
enable_signup = false). - Email/password in production → Confirm email on (
enable_confirmations = true, autoconfirm off). - You did not build a guest cart → Anonymous provider off (
enable_anonymous_sign_ins = false). - You did build a guest cart → restrictive policies that reject
auth.jwt() ->> 'is_anonymous'.
Then:
bash
curl -sS -D - "$SUPABASE_URL/auth/v1/admin/users" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY"
Anything other than 401/403: treat it as a secret-key leak and rotate.
If profiles.email was readable in step 2, fix that before you announce. Open signup plus a public email column is a harvested list.
5. Things the framework will not save you from
Server-only is a convention until you enforce it. import "server-only" and a lint rule beat "we don't call it from the client."
Preview deployments are public enough. A Vercel preview URL is on the internet. If it has the production Supabase project wired in, the preview is a production leak surface. Use a separate Supabase branch or project for previews if the data is real.
*`NEXT_PUBLIC_` is baked at build time.** Changing a Vercel env var does not change JS you already built. Redeploy after you rotate.
RLS does not apply to `service_role`. An admin client in a Route Handler is fine. The same client imported into a Client Component is not a "slightly privileged browser." It is the database.
Anonymous users are `authenticated`. A policy you wrote thinking "only logged-in people" includes them.
6. A SQL starter you can paste, then edit
Do not run this blindly on a table you have not named. Replace profiles and the column id with yours.
sql
alter table public.profiles enable row level security; revoke all on table public.profiles from anon, authenticated; grant select, update on table public.profiles to authenticated; create policy "profiles_select_own" on public.profiles for select to authenticated using ( (select auth.uid()) = id ); create policy "profiles_update_own" on public.profiles for update to authenticated using ( (select auth.uid()) = id ) with check ( (select auth.uid()) = id );
Re-run the REST curl. Then sign in as yourself and confirm your row still loads in the app.
For Storage, prefer the Dashboard Public toggle plus the per-bucket policies in the Storage post. A restrictive deny anon on storage.objects does not stop public-bucket downloads — those go through /storage/v1/object/public/… and bypass RLS. It only blocks anon listing and API access. Turn Public off on any bucket that is not a CDN.
7. Ship a URL, then keep watching it
Once the preview or production hostname is up:
- Paste the URL into hackymacky. The free preview reads client JS for secret shapes and probes the anon key against REST / OpenAPI, RPC, Storage, and write capability. You see the score, severity counts, and the top three findings with titles redacted. No account.
- Sign in, add the project, prove the hostname (DNS TXT
hackymacky-verify=…or/.well-known/security-scan.txt). That unlocks the full finding list with a copy-paste fix, plus Auth posture (settings, admin/users, identity tables, recover). - Keep the project. You get a score, history, a diff against the last full scan, and daily rescans. Score drops and new high/critical titles can email you or post to a Slack incoming webhook. Titles only — not secrets, not row dumps.
- Mark findings fixed or ignored; a recheck uses the same finding identity after a rescan.
- If you want a public signal, turn on the score badge (score only) or a shareable Markdown/PDF report (remediations, not plaintext secrets).
- Agents can call
scan_urlwith no login, or read a project report with a dashboardhm_…API key / share token.
The optional GitHub Action is a CI gate on the public preview API. It is glue, not the product. This checklist does not depend on it.
None of that replaces the curls. A green score means the probes we run did not fire. It does not mean you have a pentest.
Order of operations the day you launch
Morning: split clients, rg the build, rotate anything that showed up.
Midday: RLS grants + policies on every table in public, REST curl, Storage public/list curl.
Afternoon: Auth settings match the product, admin/users is 401, profiles are not a directory.
Evening: paste the live URL, claim the domain, read the deep findings, apply the snippets, rescan.
If you only have an hour, do steps 1, 2, and 4. Bundle secrets and world-readable tables are the ones that turn a launch tweet into an incident. Storage and Auth flags are the ones that turn a quiet week-two feature into the same incident.
The longer posts are the why. This page is the order. Run the commands against your project, not a gist, and keep the ones that failed in the PR that fixes them.