<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Sunnatulla's dev notes]]></title><description><![CDATA[Sunnatulla's dev notes]]></description><link>https://sunnatulla.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 08 Nov 2026 09:36:52 GMT</lastBuildDate><atom:link href="https://sunnatulla.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Six security holes I check for before a Supabase + Stripe app goes live]]></title><description><![CDATA[Apps built fast with Lovable, Bolt, Cursor or Claude Code tend to break in the same few places. The demo looks great, and then one of these six things is…]]></description><link>https://sunnatulla.hashnode.dev/six-security-holes-i-check-for-before-a-supabase-stripe-app-goes-live</link><guid isPermaLink="true">https://sunnatulla.hashnode.dev/six-security-holes-i-check-for-before-a-supabase-stripe-app-goes-live</guid><category><![CDATA[Security]]></category><category><![CDATA[supabase]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[stripe]]></category><dc:creator><![CDATA[Sunnatulla E.]]></dc:creator><pubDate>Sun, 08 Nov 2026 01:23:07 GMT</pubDate><content:encoded><![CDATA[<p>Apps built fast with Lovable, Bolt, Cursor or Claude Code tend to break in the same few places. The demo looks great, and then one of these six things is sitting in the repo. I wrote a small free checker for them, <a href="https://github.com/atokim4/vibe-preflight">vibe-preflight</a> (my own open-source project, MIT), and this post is the list it checks, with the fix for each one.</p>
<p>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:</p>
<pre><code>$ node preflight.mjs examples/broken-app
vibe-preflight 1.1.2 · broken-app · 5 source files, 1 SQL file · 6 checks

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. Fine for a publishable key, a leak if it's a paid API key (OpenAI, Resend, …). [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
</code></pre>
<p>The same repo has a <code>fixed-app</code> folder with every fix from this post applied. The scanner gives it a clean run:</p>
<pre><code>$ node preflight.mjs examples/fixed-app
vibe-preflight 1.1.2 · fixed-app · 7 source files, 2 SQL files · 6 checks

✓ 0 errors, 0 warnings
</code></pre>
<h2>1. A table with Row Level Security off</h2>
<pre><code class="language-sql">create table public.notes (
  id bigint generated always as identity primary key,
  user_id uuid not null references auth.users,
  body text not null
);
</code></pre>
<p>Your anon key is in the browser bundle. That's normal and fine, as long as every table in <code>public</code> has RLS on. Without it, anyone can copy the key and project URL out of dev tools and run <code>select * from notes</code> against your database through the REST API.</p>
<p><strong>Fix:</strong> turn it on in the same migration, then add policies for what users actually need.</p>
<pre><code class="language-sql">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);
</code></pre>
<p>RLS on with no policies means nobody can read anything through the API. That's a safe place to start.</p>
<h2>2. Policies that say yes to everyone</h2>
<p>The Supabase dashboard offers policy templates, and their names sound safe:</p>
<pre><code class="language-sql">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);
</code></pre>
<p>"Authenticated users only" just means anyone who signed up, and sign-up is usually open. <code>with check (true)</code> lets them insert a profile row with any <code>id</code>, including someone else's. The read policy shows every user's profile to the whole internet.</p>
<p><strong>Fix:</strong> tie each policy to the row owner.</p>
<pre><code class="language-sql">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);
</code></pre>
<p>Public read is sometimes correct. A pricing page needs <code>products</code> and <code>prices</code> for logged-out visitors, so the checker only warns about a <code>select using (true)</code> and leaves the call to you. On insert, update or delete it's an error.</p>
<p>One more thing on this table: <code>profiles</code> has a <code>plan</code> 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.</p>
<h2>3. The service_role key in browser code</h2>
<p>This one usually starts as a "quick fix" for an RLS error:</p>
<pre><code class="language-tsx">"use client";
const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
</code></pre>
<p>The service role key skips RLS completely. In Next.js this line doesn't even work at first, because variables without the <code>NEXT_PUBLIC_</code> prefix are <code>undefined</code> in client components. So the next "fix" is to rename it <code>NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY</code>, and now the master key of your database is in every visitor's JS.</p>
<p><strong>Fix:</strong> 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 and check who the user is there. Keep the client that holds the service key in a file that client code never imports. Adding <code>import "server-only"</code> at the top of that file makes the build fail if someone imports it from the browser by mistake.</p>
<h2>4. Secrets behind NEXT_PUBLIC_ (or VITE_, EXPO_PUBLIC_)</h2>
<pre><code class="language-ts">export const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);
</code></pre>
<p>Anything with that prefix is copied into the client bundle at build time. A Stripe secret key there gives anyone full use of your Stripe account, refunds and customer data included.</p>
<p>The same goes for <code>NEXT_PUBLIC_OPENAI_API_KEY</code> 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.</p>
<p><strong>Fix:</strong> drop the prefix and keep the key in server code. The browser calls your own API route instead. Then rotate the key. If it was ever in a deployed bundle, assume someone has it.</p>
<h2>5. A Stripe webhook that trusts any POST</h2>
<pre><code class="language-ts">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 });
}
</code></pre>
<p>The webhook URL is not a secret. Anyone can send this endpoint a JSON body that says <code>checkout.session.completed</code> with their own user id and get the paid plan for free.</p>
<p><strong>Fix:</strong> verify the signature on the raw body and answer 400 when it fails.</p>
<pre><code class="language-ts">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 });
}
</code></pre>
<p>Use <code>request.text()</code> and not <code>request.json()</code>. 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 <code>await stripe.webhooks.constructEventAsync(...)</code>.</p>
<h2>6. A .env file tracked by git</h2>
<p><code>.gitignore</code> only stops files that aren't tracked yet. If <code>.env</code> was committed once, before the ignore rule existed, git keeps tracking it and every later change goes into history.</p>
<p><strong>Fix:</strong></p>
<pre><code class="language-bash">git rm --cached .env
echo ".env*" &gt;&gt; .gitignore
echo "!.env.example" &gt;&gt; .gitignore
</code></pre>
<p>Then rotate every key that was in it, because the old commits still have it. Removing the file from history (<code>git filter-repo</code>) only helps if nobody cloned or forked it yet.</p>
<p>Not every committed env file is a problem. Some starters commit <code>.env.development</code> with the default keys the Supabase CLI prints for a local stack. Those are the same for everyone who runs <code>supabase start</code>, so the checker only warns about them.</p>
<h2>What I saw on real repos</h2>
<p>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:</p>
<pre><code>$ node preflight.mjs nextjs-subscription-payments
vibe-preflight 1.1.2 · nextjs-subscription-payments · 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
        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/20230530034630_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, 4 warnings

$ node preflight.mjs next-supabase-stripe-starter
vibe-preflight 1.1.2 · UPDATE_THIS_WITH_YOUR_APP_NAME · 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
</code></pre>
<p>Zero errors on both. The only warnings are the public read on <code>products</code> and <code>prices</code>, which a pricing page needs (hole 2 above, the case where <code>using (true)</code> is fine). Both starters have RLS on for every table, and their webhook handlers call <code>constructEvent</code> before touching the database.</p>
<p>That makes sense. Starters get a lot of eyes on them. 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 <code>constructEvent</code> call and added a table without RLS. Both were caught as errors.</p>
<p>The real repos were also useful for showing me where the checker was wrong. Another starter's CORS helper lists <code>stripe-signature</code> in <code>Access-Control-Allow-Headers</code>, 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.</p>
<h2>Try it</h2>
<pre><code class="language-bash">git clone https://github.com/atokim4/vibe-preflight
node vibe-preflight/skills/vibe-preflight/scripts/preflight.mjs path/to/your-app --explain
</code></pre>
<p>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. It can't see storage bucket policies or anything you only changed in the Supabase dashboard.</p>
<p>If it gets something wrong on your repo, open an issue with the output. Every false alarm I fixed so far came from running it on code I didn't write.</p>
<p><em>Disclosure: vibe-preflight is my own free, MIT-licensed side project. I used AI tools (Claude Code) to help write both the code and this post. Every command output above is a real run of version 1.1.2.</em></p>
]]></content:encoded></item></channel></rss>