DEV Community

Sunnatulla
Sunnatulla

Posted on Fully Autonomous

Six security holes I check for before a Supabase + Stripe app goes live (with the fix for each)

Apps built fast with Lovable, Bolt, Cursor or Claude Code tend to break in the same few places. The UI works, the demo looks great, and then one of these six things is sitting in the repo. I wrote a small free checker for them, vibe-preflight (my own open-source project, MIT), and this post is the list it checks, with the fix for each one.

All the code below is from a small demo app I wrote on purpose to be broken. Here is what the scanner says about it:

$ node preflight.mjs examples/broken-app

error   src/app/api/stripe/webhook/route.ts:8
        Handles Stripe events without `constructEvent`, so any POST is trusted. [stripe-webhook-unverified]
error   src/components/admin-notes.tsx:7
        admin-notes.tsx runs in the browser and uses `SUPABASE_SERVICE_ROLE_KEY`. [server-secret-in-client]
error   src/lib/stripe.ts:3
        `NEXT_PUBLIC_STRIPE_SECRET_KEY` looks like a secret and is exposed to the browser. [public-env-secret]
error   supabase/migrations/20261101000000_init.sql:8
        Policy "Enable insert for authenticated users only" on `public.profiles` (insert) lets any signed-in user write rows on behalf of any user. [rls-permissive-policy]
error   supabase/migrations/20261101000000_init.sql:14
        `public.notes` is created without `enable row level security` in any migration. [rls-disabled]
warning src/components/summarize-button.tsx:7
        `NEXT_PUBLIC_OPENAI_API_KEY` is exposed to the browser. [public-env-secret]
warning supabase/migrations/20261101000000_init.sql:11
        Policy "Enable read access for all users" on `public.profiles` (select) lets anyone (signed in or not) read every row. [rls-permissive-policy]

5 errors, 2 warnings
Enter fullscreen mode Exit fullscreen mode

1. A table with Row Level Security off

create table public.notes (
  id bigint generated always as identity primary key,
  user_id uuid not null references auth.users,
  body text not null
);
Enter fullscreen mode Exit fullscreen mode

Your anon key is in the browser bundle. That's normal and fine, as long as every table in public has RLS on. Without it, anyone can open dev tools, copy the key and the project URL, and run select * from notes against your database through the REST API.

Fix: turn it on in the same migration, then add policies for what users actually need.

alter table public.notes enable row level security;

create policy "notes: read own" on public.notes
  for select to authenticated using ((select auth.uid()) = user_id);

create policy "notes: insert own" on public.notes
  for insert to authenticated with check ((select auth.uid()) = user_id);
Enter fullscreen mode Exit fullscreen mode

RLS on with no policies means nobody can read anything through the API. That's a safe place to start.

2. Policies that say yes to everyone

The Supabase dashboard offers policy templates, and their names sound safe:

create policy "Enable insert for authenticated users only" on public.profiles
  for insert to authenticated with check (true);

create policy "Enable read access for all users" on public.profiles
  for select using (true);
Enter fullscreen mode Exit fullscreen mode

"Authenticated users only" just means anyone who signed up, and sign-up is usually open. with check (true) lets them insert a profile row with any id, including someone else's. The read policy shows every user's profile to the whole internet.

Fix: tie each policy to the row owner.

create policy "profiles: insert own" on public.profiles
  for insert to authenticated with check ((select auth.uid()) = id);

create policy "profiles: read own" on public.profiles
  for select to authenticated using ((select auth.uid()) = id);
Enter fullscreen mode Exit fullscreen mode

Public read is sometimes correct. A pricing page needs products and prices for logged-out visitors, so the checker only warns about a select using (true) and leaves the call to you. On insert, update or delete it's an error.

One more thing on this table: profiles has a plan column. Don't give users an update policy that covers it, or they can make themselves "pro" from the browser console. Let the Stripe webhook change it on the server.

3. The service_role key in browser code

This one usually starts as a "quick fix" for an RLS error:

"use client";
const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
Enter fullscreen mode Exit fullscreen mode

The service role key skips RLS completely. In Next.js this line doesn't even work at first, because variables without the NEXT_PUBLIC_ prefix are undefined in client components. So the next "fix" is to rename it NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY, and now the master key of your database is in every visitor's JS.

Fix: use the anon key with the user's session in the browser, and write the policies that make it work. If something really needs admin rights, do it in a Route Handler or Server Action, check who the user is there, and keep the client with the service key in a file that never gets imported by client code. Adding import "server-only" at the top of that file makes the build fail if someone imports it from the browser by mistake.

4. Secrets behind NEXT_PUBLIC_ (or VITE_, EXPO_PUBLIC_)

export const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);
Enter fullscreen mode Exit fullscreen mode

Anything with that prefix is copied into the client bundle at build time. A Stripe secret key there means anyone can create refunds, read your customers or issue payouts with your account.

The same goes for NEXT_PUBLIC_OPENAI_API_KEY used for an "AI summarize" button. You'll find out when the bill comes. The checker only warns on that one, because some services do have keys made for the browser.

Fix: drop the prefix, use the key only in server code, and call your own API route from the browser. Then rotate the key. If it was ever in a deployed bundle, assume someone has it.

5. A Stripe webhook that trusts any POST

export async function POST(request: Request) {
  const event = await request.json();
  if (event.type === "checkout.session.completed") {
    await admin.from("profiles").update({ plan: "pro" }).eq("id", event.data.object.client_reference_id);
  }
  return Response.json({ received: true });
}
Enter fullscreen mode Exit fullscreen mode

The webhook URL is not a secret. Anyone can send this endpoint a JSON body that says checkout.session.completed with their own user id and get the paid plan for free.

Fix: read the raw body, verify the signature, and answer 400 when it fails.

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  let event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }
  // handle event.type here
  return Response.json({ received: true });
}
Enter fullscreen mode Exit fullscreen mode

Use request.text() and not request.json(). The signature is computed over the exact bytes Stripe sent, and parsing then re-serializing the JSON breaks it. On Deno or edge runtimes it's await stripe.webhooks.constructEventAsync(...).

6. A .env file tracked by git

.gitignore only stops files that aren't tracked yet. If .env was committed once, before the ignore rule existed, git keeps tracking it and every later change goes into history.

Fix:

git rm --cached .env
echo ".env*" >> .gitignore
echo "!.env.example" >> .gitignore
Enter fullscreen mode Exit fullscreen mode

Then rotate every key that was in it, because the old commits still have it. Removing the file from history (git filter-repo) only helps if nobody cloned or forked it yet.

Not every committed env file is a problem. Some starters commit .env.development with the default keys the Supabase CLI prints for a local stack. Those are the same for everyone who runs supabase start, so the checker only warns about them.

What I saw on real repos

Before writing this I ran the checker (1.1.2) on public MIT-licensed Next.js + Supabase + Stripe starters. Here is the full output for two of them, the official Vercel subscription starter and a popular community starter built on the same schema:

$ node preflight.mjs nextjs-subscription-payments
vibe-preflight 1.1.2 · 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

$ node preflight.mjs next-supabase-stripe-starter
vibe-preflight 1.1.2 · 57 source files, 2 SQL files · 6 checks

warning supabase/migrations/20240115041359_init.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 supabase/migrations/20240115041359_init.sql:99
        Policy "Allow public read-only access." on `public.prices` (select) lets anyone (signed in or not) read every row. [rls-permissive-policy]

0 errors, 2 warnings
Enter fullscreen mode Exit fullscreen mode

Zero errors on both. The only warnings are the public read on products and prices, which a pricing page needs (hole 2 above, the case where using (true) is fine). Both starters turn RLS on for every table, keep the service key in server code and verify webhook signatures with constructEvent.

That makes sense, starters get many eyes. The problems above show up later, in the app someone builds on top of a starter while chasing an error message at 2am. To check that the scanner would notice, I copied one starter, removed the constructEvent call and added a table without RLS. Both were caught as errors.

The real repos were also useful for showing me where the checker was wrong. Another starter's CORS helper lists stripe-signature in Access-Control-Allow-Headers, and the checker took that file for a webhook handler with no verification. It now only counts a file that actually reads the header. I'd rather fix a false alarm than have people turn a check off.

Try it

git clone https://github.com/atokim4/vibe-preflight
node vibe-preflight/skills/vibe-preflight/scripts/preflight.mjs path/to/your-app --explain
Enter fullscreen mode Exit fullscreen mode

It's one Node 20+ file with no dependencies, and it can also be installed as a Claude Code skill (instructions in the README). It's a static check, so a clean run doesn't mean an app is secure. Storage bucket policies, auth settings and anything you only changed in the Supabase dashboard are out of its reach.

If it gets something wrong on your repo, open an issue with the output. Both false alarms I fixed so far came from running it on code I didn't write.

If you want more than six checks, I also sell a paid kit built around the same idea: a 21-check scanner plus Claude Code / Cursor rules and a pre-launch checklist for this stack (Vibe App Production Kit, $29). The free checker above works on its own, you don't need the kit.

Disclosure: vibe-preflight is my own free tool and the kit is my own paid product. This post was drafted with AI tools (Claude) from the project's real output: every command shown was actually run, and the output is copied as is.

Top comments (1)

Collapse
 
elijahbrown profile image
Elijah Brown •

Good list. The point in #2 that "authenticated" just means anyone who signed up goes further once there's an AI button like the summarize one. Even with the OpenAI key moved server-side, a script can open a new account per throwaway inbox and get a fresh quota each time. Requiring a confirmed email before that route calls OpenAI, and keying the free quota to a normalised address (lowercase, Gmail dots and +tags stripped) instead of the user id, closes most of it. Might be a fair seventh check for the scanner, though it's harder to spot statically.