<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Perufitlife</title>
    <description>The latest articles on DEV Community by Perufitlife (@perufitlife).</description>
    <link>https://dev.to/perufitlife</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3897417%2F545b848b-bfb9-4725-95f7-29d6af2e1bc7.png</url>
      <title>DEV Community: Perufitlife</title>
      <link>https://dev.to/perufitlife</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/perufitlife"/>
    <language>en</language>
    <item>
      <title>I shipped a live integration sandbox in 90 minutes instead of taking the partnership call. It changed the conversation.</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 12 May 2026 12:15:35 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-shipped-a-live-integration-sandbox-in-90-minutes-instead-of-taking-the-partnership-call-it-48df</link>
      <guid>https://dev.to/perufitlife/i-shipped-a-live-integration-sandbox-in-90-minutes-instead-of-taking-the-partnership-call-it-48df</guid>
      <description>&lt;p&gt;A CEO sent me a DM yesterday afternoon. Aviation learning platform. Wanted to talk about a partnership with my smaller aviation API (Rotatepilot — question banks, METAR decoder, airport lookups). Real founder, real platform, real intent.&lt;/p&gt;

&lt;p&gt;I had two options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reply with my usual qualifying questions, schedule a 30 min call later in the week, do the meeting, agree on what to build, build it, send it for review, iterate.&lt;/li&gt;
&lt;li&gt;Build it now and send the link.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I went with option 2. 90 minutes of work. The conversation flipped from "show me what this looks like" to "what's the commercial shape."&lt;/p&gt;

&lt;p&gt;Here's how, and why I'll do this for every inbound partnership conversation from now on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DM that triggered this
&lt;/h2&gt;

&lt;p&gt;From the CEO of SkyX International (aviation learning platform, Public Beta on Product Hunt):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We are interested in your RotatePilot website, since it includes crucial tools that we think can help us develop a large selection of aviation tools. Your data bank (ATPL/PPL questions, API…) also fascinates us. We think a partnership between both platforms could benefit you and us. It could either be an API, embed, or any kind of licensing partnership. We are open and flexible. Before engaging into anything, I invite you to take a look at our platform, test out the features as you want, and feel free to give your suggestions."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Translation: "I want to integrate your API but I don't know what your API looks like yet, and you don't know what we'd actually consume."&lt;/p&gt;

&lt;p&gt;The default founder-to-founder script here is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"Great, here's a Calendly link, let's do 30 min."&lt;/li&gt;
&lt;li&gt;On the call, you screen-share your API docs, they ask questions, you both end with "send me a sample integration."&lt;/li&gt;
&lt;li&gt;You build the sample. You send it. They review. Maybe respond in a week. Maybe not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I've done that loop probably 20 times in the last year. The conversion rate is awful. Most never come back. The ones that do, come back 2-3 weeks later by which time the energy has dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did instead
&lt;/h2&gt;

&lt;p&gt;Spent 5 minutes browsing their platform (onboarding.skyxintl.me). Took specific notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They list "structured learning pathways + spaced repetition" but no question bank size visible.&lt;/li&gt;
&lt;li&gt;They list "METAR decoder" as a feature.&lt;/li&gt;
&lt;li&gt;I didn't see a language switcher on the landing — looked English-only.&lt;/li&gt;
&lt;li&gt;They're pre-launch (countdown timer at 00:00:00:00).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are gaps where my API fills in. Not theoretical fit, actual fit, with specifics.&lt;/p&gt;

&lt;p&gt;Then I built &lt;a href="https://github.com/Perufitlife/rotatepilot-skyx-sandbox" rel="noopener noreferrer"&gt;&lt;code&gt;rotatepilot-skyx-sandbox&lt;/code&gt;&lt;/a&gt; — a single HTML file that hits four of my public REST endpoints live in the browser and renders the responses in a learning-platform style UI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Question bank&lt;/strong&gt; — &lt;code&gt;GET /api/v1/question?subject=meteorology&amp;amp;count=2&lt;/code&gt; — returns FAA-labeled MCQs with explanations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;METAR decoder&lt;/strong&gt; — &lt;code&gt;GET /api/v1/metar?icao=KJFK&lt;/code&gt; — returns decoded flight category, wind, visibility, cloud layers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Airport lookup&lt;/strong&gt; — &lt;code&gt;GET /api/v1/airport/KSFO&lt;/code&gt; — returns name, ICAO, runways, training-airport flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quiz of the day&lt;/strong&gt; — &lt;code&gt;GET /api/v1/quiz-of-day&lt;/code&gt; — returns one shared question for daily-engagement features.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is one file. No build step. No backend. Calls go straight from the browser to my edge-served endpoints (CORS enabled, no auth tier needed at this volume). Open DevTools and you see the real requests.&lt;/p&gt;

&lt;p&gt;Stack: ~300 lines of HTML/CSS/vanilla JS. Pushed to GitHub. Enabled GitHub Pages with &lt;code&gt;gh api -X POST repos/.../pages -f "source[branch]=master"&lt;/code&gt;. Live in 90 seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://perufitlife.github.io/rotatepilot-skyx-sandbox/" rel="noopener noreferrer"&gt;Live demo&lt;/a&gt; — paste any ICAO, pick any subject, see real responses.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I sent back to the CEO
&lt;/h2&gt;

&lt;p&gt;Three messages, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Qualifying questions&lt;/strong&gt; — ranked top-2 pieces, format (API vs embed vs license), 6-month volume guess, commercial direction. Plus a 30-min call offer for later in the week if he preferred async.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substantive feedback on their platform&lt;/strong&gt; — what looked strong, three peer-pushback notes (the countdown timer reads 00:00:00:00, no product screenshot above the fold, Product Hunt badge buried in footer), and a concrete integration proposal direction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The sandbox link&lt;/strong&gt; — "Instead of waiting on the call to show what the JSON contract looks like, I went ahead and built the sandbox. It's a single static page that hits 4 endpoints live. Take a look when you have 5 min — would help us skip a lot of 'what does it look like' on the call and jump straight to commercial shape."&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total time: roughly 90 minutes of real work, including writing the messages and verifying the live endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this changes the conversation
&lt;/h2&gt;

&lt;p&gt;The default partnership conversation has three rounds of friction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Discovery&lt;/strong&gt; — what do you have, what do we want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mapping&lt;/strong&gt; — does the shape match, what would integration cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commercial&lt;/strong&gt; — how do we make money on this.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A sandbox collapses rounds 1 and 2 into a single artifact the other side can open and inspect on their own time. They don't need to wait for a meeting to understand the shape of what you have. You don't need to spend the meeting explaining endpoints. The meeting (if it happens) is just round 3, which is the only round that actually moves the deal.&lt;/p&gt;

&lt;p&gt;It also forces you to do the work &lt;em&gt;before&lt;/em&gt; you've gotten a "yes," which is exactly the work that makes the partnership real. Half the partnerships I've talked about never happened because the work to make them concrete never got done. Doing it on day zero filters out partners who weren't going to follow through anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general pattern
&lt;/h2&gt;

&lt;p&gt;You don't need someone's permission to demonstrate value. The thing that's expensive isn't the meeting, it's the &lt;em&gt;uncertainty&lt;/em&gt; between "we should partner" and "let's actually integrate." If you can collapse the uncertainty in 90 minutes of static-file work, do it.&lt;/p&gt;

&lt;p&gt;This works for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API-led partnerships&lt;/strong&gt; — show your endpoints rendering in their kind of UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed/widget integrations&lt;/strong&gt; — drop an iframe in a styled wrapper that resembles their site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data licensing&lt;/strong&gt; — a quick HTML view of a sample of your dataset with proposed fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold outbound to a specific company&lt;/strong&gt; — same idea, build a custom demo and lead with it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does not work for vague "let's chat" inbounds where the asker doesn't know what they want. That's a different problem (qualifying, not delivering).&lt;/p&gt;

&lt;h2&gt;
  
  
  The tools, for replicability
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One HTML file. No framework.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;git init &amp;amp;&amp;amp; git add . &amp;amp;&amp;amp; git commit &amp;amp;&amp;amp; gh repo create --public --push&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gh api -X POST "repos/USER/REPO/pages" -f "source[branch]=master" -f "source[path]=/"&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Live URL: &lt;code&gt;https://USER.github.io/REPO/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Source for the sandbox I built: &lt;a href="https://github.com/Perufitlife/rotatepilot-skyx-sandbox" rel="noopener noreferrer"&gt;github.com/Perufitlife/rotatepilot-skyx-sandbox&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What I'm doing with this pattern next: applying it to every cold outbound I send for my BaaS security auditors. Instead of "want a free scan?" → "here's a sandbox of what a scan of your project might find, formatted as a real audit report." Same effort, 10x higher conversion.&lt;/p&gt;

&lt;p&gt;If you've shipped a sandbox-before-meeting and it worked (or didn't), I'd love to hear what you learned. Reply or DM.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building a portfolio of single-purpose dev tools at &lt;a href="https://github.com/Perufitlife" rel="noopener noreferrer"&gt;github.com/Perufitlife&lt;/a&gt;. Latest: five open-source BaaS security auditors (Supabase, Firebase, PocketBase, Appwrite, Nhost) shipped this week.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>partnership</category>
      <category>api</category>
      <category>buildinpublic</category>
      <category>startup</category>
    </item>
    <item>
      <title>I shipped a public Apify actor that scans Supabase projects for RLS leaks (took 90 min, found a 895-record leak on the first real test run)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 12 May 2026 07:42:40 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-shipped-a-public-apify-actor-that-scans-supabase-projects-for-rls-leaks-took-90-min-found-a-33c</link>
      <guid>https://dev.to/perufitlife/i-shipped-a-public-apify-actor-that-scans-supabase-projects-for-rls-leaks-took-90-min-found-a-33c</guid>
      <description>&lt;p&gt;Just shipped a new public Apify actor: &lt;a href="https://apify.com/renzomacar/supabase-rls-scanner" rel="noopener noreferrer"&gt;Supabase RLS Security Scanner&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does&lt;/strong&gt;: paste your Supabase URL + anon key, get back a JSON report (plus a pretty HTML report) listing every table that's anonymously readable, with row counts and a curl reproducer for each finding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this exists&lt;/strong&gt;: the Supabase anon key is &lt;strong&gt;meant to be public&lt;/strong&gt; — it ships in your frontend. The only thing keeping your tables private should be Row-Level Security. Across the 30 Supabase projects I scanned this week, ~10% had RLS forgotten on at least one user-data table (&lt;code&gt;profiles&lt;/code&gt;, &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;accounts&lt;/code&gt;, etc.). One I scanned this morning had &lt;strong&gt;895 staff records with email + phone exposed&lt;/strong&gt; to anyone with the public anon key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs&lt;/strong&gt;: free to run on Apify free plan (cheap Apify compute). I'll publish per-scan pricing in the next update once the actor has 50+ runs of validation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does NOT do&lt;/strong&gt;: never reads row contents. Uses &lt;code&gt;Prefer: count=exact&lt;/code&gt; + &lt;code&gt;Range: 0-0&lt;/code&gt; to confirm a leak exists without touching the data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Use it via API:&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/renzomacar~supabase-rls-scanner/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN"&lt;/span&gt;   &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt;   &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "supabaseUrl": "https://YOUR-PROJECT.supabase.co",
    "anonKey": "eyJ...your-anon-key...",
    "outputFormat": "both"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or the open-source CLI version (free, runs entirely on your machine):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @perufitlife/supabase-security &lt;span class="nt"&gt;--discover&lt;/span&gt; &lt;span class="nt"&gt;--url&lt;/span&gt; YOUR_URL &lt;span class="nt"&gt;--key&lt;/span&gt; YOUR_ANON_KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Output excerpt&lt;/strong&gt; from a real scan I ran this morning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"findings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"staff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;895&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"critical"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sensitiveColumns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"reproducer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"curl '.../rest/v1/staff?select=*' -H 'apikey: ...' -H 'Prefer: count=exact' -H 'Range: 0-0' -I"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total_anon_readable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total_exposed_records"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;895&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I'm using this for&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;My weekly scan service&lt;/strong&gt; — &lt;a href="https://rls-monitor.vercel.app/" rel="noopener noreferrer"&gt;rls-monitor.vercel.app&lt;/a&gt; ($29/mo) runs this against your project every week, alerts you the second a new leak shows up.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Free responsible-disclosure work&lt;/strong&gt; — I'm offering free scans to the first 20 r/Supabase folks who DM me their URL this weekend (&lt;a href="https://old.reddit.com/r/Supabase/comments/1taubbv/ill_scan_your_supabase_project_for_free_this/" rel="noopener noreferrer"&gt;reddit post here&lt;/a&gt;). Inbound only — I don't scan projects without explicit permission from the owner.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sister scanners are coming&lt;/strong&gt; — Firebase, PocketBase, Appwrite, and Nhost versions all live as npm CLIs (&lt;a href="https://www.npmjs.com/~perufitlife" rel="noopener noreferrer"&gt;@perufitlife on npm&lt;/a&gt;); the Apify-actor versions are next.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If this saves you from a real leak, please leave a review on the &lt;a href="https://apify.com/renzomacar/supabase-rls-scanner" rel="noopener noreferrer"&gt;Apify store page&lt;/a&gt;. That's the engine that keeps me prioritizing this work.&lt;/p&gt;

&lt;p&gt;Open-source repo + docs: &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;github.com/Perufitlife/supabase-security-skill&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;— Renzo&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>apify</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I scanned 30 Supabase repos this morning and found 3 production-grade leaks (one with service_role committed)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 12 May 2026 07:19:55 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-scanned-30-supabase-repos-this-morning-and-found-3-production-grade-leaks-one-with-servicerole-22ce</link>
      <guid>https://dev.to/perufitlife/i-scanned-30-supabase-repos-this-morning-and-found-3-production-grade-leaks-one-with-servicerole-22ce</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;This morning I ran a wider sweep of public GitHub repos that import &lt;code&gt;@supabase/supabase-js&lt;/code&gt; and have anything resembling an anon key (or worse, a service_role key) in committed code.&lt;/p&gt;

&lt;p&gt;Out of ~30 repos I probed (responsibly — counts only, no row contents), &lt;strong&gt;three had production-grade leaks&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Chinese AI paper-writing tool with 1,669 stars and 1,843 user profile records readable anonymously.&lt;/li&gt;
&lt;li&gt;Two indie-SaaS repos with the &lt;code&gt;service_role&lt;/code&gt; key committed to &lt;code&gt;.env.local&lt;/code&gt; — meaning anyone who finds the repo can read/write/delete every row in their database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three got responsible-disclosure pings (private email or GitHub issue) with a free DIY fix walkthrough + a paid turnkey option. We'll see what happens.&lt;/p&gt;

&lt;p&gt;This post is about the &lt;strong&gt;method + monetization frame&lt;/strong&gt;, not naming specific projects. If you want to scan your own project, the CLI is open source: &lt;a href="https://www.npmjs.com/package/@perufitlife/supabase-security" rel="noopener noreferrer"&gt;@perufitlife/supabase-security&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method (90 seconds per repo)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Fetch the file that imports createClient&lt;/span&gt;
curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="s2"&gt;"https://raw.githubusercontent.com/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;/main/path/to/file.ts"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Grep for hardcoded URL + key (most repos use env vars properly,&lt;/span&gt;
&lt;span class="c"&gt;#    but ~10% commit a fallback or a .env.local for "convenience")&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'https://[a-z0-9-]+.supabase.co'&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'eyJ[A-Za-z0-9_-]+.eyJ[A-Za-z0-9_-]+.[A-Za-z0-9_-]+'&lt;/span&gt;

&lt;span class="c"&gt;# 3. Probe common table names with the anon key — counts only, no data&lt;/span&gt;
curl &lt;span class="nt"&gt;-sLI&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;/rest/v1/profiles?select=*"&lt;/span&gt;   &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Prefer: count=exact"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Range: 0-0"&lt;/span&gt;   | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"content-range"&lt;/span&gt;

&lt;span class="c"&gt;# Output like: Content-Range: 0-0/1843&lt;/span&gt;
&lt;span class="c"&gt;# Means: 1843 profile records readable by anyone with the anon key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No clever exploitation, no zero-days. Just: did the repo commit a key, is RLS on, what's the count.&lt;/p&gt;

&lt;h2&gt;
  
  
  What % of repos are leaking
&lt;/h2&gt;

&lt;p&gt;Across the ~30 I tested today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~10% had a hardcoded URL + working anon key&lt;/strong&gt;. Most do this as a fallback in code (&lt;code&gt;process.env.X || 'hardcoded'&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~50% of those had RLS disabled on at least one user-data table&lt;/strong&gt; (profiles, users, accounts, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 had service_role keys committed&lt;/strong&gt;. That's the catastrophic one — bypasses ALL RLS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the back-of-envelope hit rate for "find a real leak" is around 1-in-15 to 1-in-30 repos. Not high enough to spray-and-pray, but high enough that targeted searches (specific stacks, specific verticals) yield gold.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monetization side
&lt;/h2&gt;

&lt;p&gt;For folks building security tooling — the play I'm running:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Differential pricing by severity.&lt;/strong&gt; A toy project hobbyist gets the free DIY walkthrough. A 1k-star app with paying Pro users gets pitched a $249 written audit + attestation. A B2B SaaS with service_role leaked gets pitched a $499-999 incident-response package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Recurring monitoring upsell&lt;/strong&gt; ($29/mo). One-time fix = $99. Continuous monitoring with weekly diff scans = $348/yr. The math favors recurring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Bug bounty programs.&lt;/strong&gt; Check HackerOne, BugCrowd, Intigriti for the target's name before doing free disclosure. Verified critical findings pay $500-10K. (None of my 3 today were on a bounty program.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Vertical specialization.&lt;/strong&gt; Healthcare app leaking patient data → HIPAA-adjacent compliance angle, different price tier. Finance app → PCI-DSS angle. Education → FERPA. Same scan, different anchoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Aggregated SEO posts&lt;/strong&gt; (this one). Each scan batch becomes a post. Each post drives inbound. The compound is the brand, not the individual sale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Stack expansion.&lt;/strong&gt; Same pattern works on Firebase (&lt;code&gt;firebase-security&lt;/code&gt; package), PocketBase, Appwrite, Nhost. We have all 5. Each new stack = new search surface area.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Cyber-insurance affiliate.&lt;/strong&gt; Some insurers underwrite SaaS cyber policies and need risk signal pre-issue. Each verified leak finding can be sold as risk-assessment data ($50-200 per lead) to the right broker.&lt;/p&gt;

&lt;p&gt;The ethical line I'm holding: never read row data, never publish identifying info pre-disclosure, always give a free DIY fix path. The paid offer is for the people who'd rather pay than learn the SQL. Both paths fix the leak. That's the actual goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm doing next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Expanding the scanner search to Bolt + Lovable + Replit "vibe-coded" Supabase apps. Reddit reports 33% RLS issue rate on those. Lower-conversion targets (mostly toy projects) but easier to scale.&lt;/li&gt;
&lt;li&gt;Same pattern on Firebase / PocketBase / Appwrite. Already have the scanners (&lt;a href="https://www.npmjs.com/~perufitlife" rel="noopener noreferrer"&gt;Perufitlife on npm&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Weekly RLS Monitor — $29/mo that re-scans your project and alerts you on the first new leak. &lt;a href="https://rls-monitor.vercel.app/" rel="noopener noreferrer"&gt;rls-monitor.vercel.app&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've shipped a Supabase project to production and never explicitly written RLS policies on every table, you are &lt;em&gt;most likely&lt;/em&gt; in the ~50% that leaks. Free CLI scan takes 5 minutes: &lt;code&gt;npx @perufitlife/supabase-security --discover&lt;/code&gt; (your keys never leave your terminal).&lt;/p&gt;

&lt;p&gt;If you'd rather pay someone to do the audit + write the policies turnkey, $99 (one-time): &lt;a href="https://buy.stripe.com/00w9AT9TWdaW7yx9KkcAo01" rel="noopener noreferrer"&gt;stripe link&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;— Renzo (perufitlife on GitHub / npm)&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>webdev</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>I expanded my niche AI factory from 9 to 15 generators in under an hour</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 12 May 2026 06:24:31 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-expanded-my-niche-ai-factory-from-9-to-15-generators-in-under-an-hour-38n3</link>
      <guid>https://dev.to/perufitlife/i-expanded-my-niche-ai-factory-from-9-to-15-generators-in-under-an-hour-38n3</guid>
      <description>&lt;p&gt;Yesterday I wrote about a 2-hour panic-build: 9 niche AI generators shipped on one factory backend after 24h of $0 sales on a generic humanizer. (&lt;a href="https://dev.to/perufitlife/how-i-shipped-the-rewriter-side-of-an-ai-tell-detector-in-30-minutes-claude-nextjs-vercel-13g0"&gt;previous post&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Today I extended that factory from 9 to 15 generators in under an hour. Not because I think 15 is the right number — because the marginal cost was so low it'd be irrational not to keep planting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 6 I added
&lt;/h2&gt;

&lt;p&gt;All at &lt;code&gt;aitells.vercel.app/&amp;lt;slug&amp;gt;&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/thank-you-note&lt;/code&gt; — gifts, weddings, post-interview, sympathy. Names the specific gift, 80-130 words.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/breakup-text&lt;/code&gt; — firm + kind, names what's ending, one real reason, no "let's stay friends" unless requested.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/instagram-caption&lt;/code&gt; — first-line hook readable before "more", one specific detail, 5-8 mixed hashtags (not 30).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/linkedin-post&lt;/code&gt; — no "I'm humbled to announce", first-line hook, one concrete claim + one specific story or number.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/reference-letter&lt;/code&gt; — 2-3 specific examples with outcomes, one honest growth area framed as context.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/out-of-office&lt;/code&gt; — clear dates, response expectations, backup contact, no double "in my absence".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is free preview → $9 one-time unlocks all 15 generators plus the rewriter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of generator #15 vs #1
&lt;/h2&gt;

&lt;p&gt;Generator #1 (eulogy) took me about 2 hours: building the factory, the reusable client component, the Stripe gate, the template structure.&lt;/p&gt;

&lt;p&gt;Generator #15 (out-of-office) took 12 minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add one Template object to &lt;code&gt;/api/generate/route.ts&lt;/code&gt; (~30 lines: systemPrompt, buildUserPrompt, maxTokens, previewWords).&lt;/li&gt;
&lt;li&gt;Create one &lt;code&gt;page.tsx&lt;/code&gt; with &lt;code&gt;&amp;lt;GeneratorClient templateId="..." fields={[...]} /&amp;gt;&lt;/code&gt; and a metadata block.&lt;/li&gt;
&lt;li&gt;Add the URL to &lt;code&gt;sitemap.ts&lt;/code&gt; and a card to the homepage hub.&lt;/li&gt;
&lt;li&gt;Deploy. Smoke test. IndexNow ping.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The bottleneck is no longer code. It's &lt;strong&gt;knowing which niche is worth shipping&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I pick the next niche
&lt;/h2&gt;

&lt;p&gt;After a day of staring at this, my heuristic is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Single-purpose Google query exists.&lt;/strong&gt; Someone searches "ai apology letter" or "ai wedding toast". The verb is bounded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pain has a deadline.&lt;/strong&gt; Wedding next month, funeral this week, performance review due Friday. Generic tools don't compete because urgency burns through indecision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output is too important to half-ass and too small to hire a professional for.&lt;/strong&gt; $9 is cheaper than 2 hours of your time. A pro freelancer is $200.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The buyer doesn't want to share that they used AI.&lt;/strong&gt; Apology letter, breakup text, eulogy, college essay. They're not going to subscribe to your newsletter and tweet about you. One-time payment, no friction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Out of these 15, the ones I'm betting are the strongest on this rubric: eulogy, best-man-speech, apology-letter, breakup-text, college-essay. The rest are surface-area bets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture (now with 6 more templates)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/api/generate/route.ts                  ← 15 templates in one TEMPLATES dict
  POST {template_id, inputs} → {ok, preview, full, word_count}

/_generators/GeneratorClient.tsx        ← one reusable client component
  takes {templateId, title, subtitle, fields[], pricePill, ctaPrice}
  renders form → POST /api/generate → preview → Stripe CTA if not paid

/eulogy/page.tsx                        ← 30 lines
/breakup-text/page.tsx                  ← 30 lines
/out-of-office/page.tsx                 ← 30 lines
... etc x 15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a 16th niche right now is a 10-15 minute job. The interesting thing isn't the code — it's that the &lt;strong&gt;incremental risk is also tiny&lt;/strong&gt;. If one niche flops, it lives at &lt;code&gt;/&amp;lt;slug&amp;gt;&lt;/code&gt; forever earning $0 with negligible token cost. If one hits, I have the infra to add 5 cousins to it in an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's not done
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cross-device unlock.&lt;/strong&gt; The &lt;code&gt;aitells_lifetime&lt;/code&gt; flag is in localStorage. Buy on phone, can't use on laptop yet. Fixing this with a Supabase table + magic link after I hit 5 sales — not before.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A/B testing different price points.&lt;/strong&gt; Single $9 Stripe link for now. Will add $7 / $9 / $14 splits after first sale to learn elasticity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brand-safe variants.&lt;/strong&gt; Right now the LinkedIn-post generator outputs my preferred style (direct, slightly contrarian). Some users want corporate-LinkedIn-speak. Future field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search Console traction signal.&lt;/strong&gt; New URLs go live → IndexNow pings → Bing/Yandex pick up first → Google indexes in 1-3 weeks. ChatGPT/Perplexity citations come last (4-8 weeks). I'll know in mid-June whether the factory thesis is right.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest revenue check
&lt;/h2&gt;

&lt;p&gt;15 generators live. $0 in sales as of writing. I'm 36 hours into this approach. The cost has been near-zero (Vercel free, Anthropic tokens ~$0.30 total so far for testing all 15 endpoints).&lt;/p&gt;

&lt;p&gt;If even ONE generator gets first-page Google in 4 weeks, I'm in the money. If three of them do, I'll ship 30 more.&lt;/p&gt;

&lt;p&gt;The factory: &lt;a href="https://aitells.vercel.app" rel="noopener noreferrer"&gt;aitells.vercel.app&lt;/a&gt; — the header has cards for all 15.&lt;/p&gt;

&lt;p&gt;Cross-pollinating with my BaaS auditor work too — same factory pattern: one &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;supabase-security&lt;/a&gt; core, package 5 ways. Different domain, same compounding logic.&lt;/p&gt;




&lt;p&gt;If you've shipped niche AI wrappers and can share how indexation went for you — drop a comment. Especially curious about people in the 4-12 week SEO range with similar one-purpose URLs.&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>ai</category>
      <category>nextjs</category>
      <category>startup</category>
    </item>
    <item>
      <title>I shipped 9 AI niche generators in 2 hours after my generic SaaS got 0 sales (the factory pattern)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 12 May 2026 06:17:32 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-shipped-9-ai-niche-generators-in-2-hours-after-my-generic-saas-got-0-sales-the-factory-pattern-2m3c</link>
      <guid>https://dev.to/perufitlife/i-shipped-9-ai-niche-generators-in-2-hours-after-my-generic-saas-got-0-sales-the-factory-pattern-2m3c</guid>
      <description>&lt;p&gt;After 24 hours of trying to sell a generic "AI text humanizer" (0 sales), I sat down and did what I should've done first: looked at what's actually selling in indie SaaS land.&lt;/p&gt;

&lt;p&gt;I scraped a GitHub list of 373 micro-SaaS that are running today. Read the descriptions. Looked for the pattern.&lt;/p&gt;

&lt;p&gt;The pattern that wins: &lt;strong&gt;one specific use case, one specific pain, one $9-$19 transaction&lt;/strong&gt;. Not "AI text humanizer" (too generic, who's the buyer?). Specific things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI Wedding Toast&lt;/li&gt;
&lt;li&gt;AI Cover Letter Generator&lt;/li&gt;
&lt;li&gt;AI PowerPoint Maker&lt;/li&gt;
&lt;li&gt;AI Apology Letter&lt;/li&gt;
&lt;li&gt;AI Eulogy Generator&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The buyer has a wedding next month. A funeral this week. A job application due Sunday. They Google "ai eulogy generator", land on something that asks 5 specific questions, get a usable speech, hit the $9 button. Done.&lt;/p&gt;

&lt;p&gt;I'd been building "aitells — AI text detector + humanizer" for 24h and gotten zero buyers. Today I sat down and shipped 9 single-purpose generators on top of the same backend, in 2 hours total.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped
&lt;/h2&gt;

&lt;p&gt;All at &lt;code&gt;aitells.vercel.app/&amp;lt;name&amp;gt;&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;/eulogy&lt;/code&gt; — funeral speech in 30 seconds, 3-4 min spoken&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/best-man-speech&lt;/code&gt; — wedding toast that gets the room&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/wedding-toast&lt;/code&gt; — maid of honor, father of bride, etc.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/apology-letter&lt;/code&gt; — the kind that lands, no "sorry if you felt"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/resignation-letter&lt;/code&gt; — close the door cleanly&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/performance-review&lt;/code&gt; — concrete, no "wears many hats" word soup&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/cover-letter&lt;/code&gt; — past the first sentence&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/college-essay&lt;/code&gt; — Common App, voice of a 17-year-old&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/tinder-bio&lt;/code&gt; — right-swipe hooks&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each is free preview → $9 unlock-everything (one Stripe link unlocks all generators + the rewriter on the same site).&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture (under 80 lines of repeated code per generator)
&lt;/h2&gt;

&lt;p&gt;The pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/api/generate/route.ts                  ← one factory endpoint
  TEMPLATES = { "eulogy": {...}, "best-man-speech": {...}, ... }
  POST {template_id, inputs} → {ok, preview, full, word_count}

/_generators/GeneratorClient.tsx        ← one reusable client component
  takes {templateId, title, subtitle, fields[]} as props
  renders form → POST /api/generate → render preview → Stripe CTA if not paid

/eulogy/page.tsx                        ← 30 lines, mostly props
  &amp;lt;GeneratorClient templateId="eulogy" title="..." fields={...} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a 10th generator is: write one Template object in the factory, write one page.tsx with the field list, deploy. About 20 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works (theory of the case)
&lt;/h2&gt;

&lt;p&gt;The chatgpt.com referrer signal: when someone asks ChatGPT "I need help writing X", ChatGPT either writes it inline OR recommends a tool. For ChatGPT to recommend you, you need:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A clean, narrow URL: &lt;code&gt;aitells.vercel.app/eulogy&lt;/code&gt; beats &lt;code&gt;aitells.vercel.app/?type=eulogy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;SEO metadata that says exactly what you do&lt;/li&gt;
&lt;li&gt;JSON-LD &lt;code&gt;SoftwareApplication&lt;/code&gt; schema (already on the homepage, propagating)&lt;/li&gt;
&lt;li&gt;Real text content explaining the differentiation, not just a form&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I shipped 9 narrow URLs in one afternoon. In 4-8 weeks, Google will index them and ChatGPT/Claude/Perplexity will start citing them. Until then, IndexNow pinged Bing/Yandex for faster discovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm watching now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe for the first $9 buy&lt;/li&gt;
&lt;li&gt;Google Search Console for impressions per landing (in 2-3 weeks)&lt;/li&gt;
&lt;li&gt;ChatGPT referrer signups (the leading indicator that the niche pages got picked up)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If even 2 generators get traction, I'll ship 20 more in 5 hours. The factory is built. The only marginal cost per generator is the AI tokens for that one buyer's preview + full speech (~$0.02), plus 20 min of my time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest postmortem on the first 24h
&lt;/h2&gt;

&lt;p&gt;Six hours yesterday went into a generic "AI text humanizer". It still works. It still has $0 in sales. The market was telling me the offer was too generic. I kept polishing instead of pivoting.&lt;/p&gt;

&lt;p&gt;When the market gives you a 0, the answer is rarely "do the same thing better". It's "do a much more specific thing".&lt;/p&gt;




&lt;p&gt;I also build &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;supabase-security&lt;/a&gt; and 4 sister BaaS auditors. Same factory pattern: build one core, package 5 ways. Works for SaaS just like it works for content generators.&lt;/p&gt;

&lt;p&gt;The factory: &lt;a href="https://aitells.vercel.app" rel="noopener noreferrer"&gt;aitells.vercel.app&lt;/a&gt; (lists all 9 generators in the header).&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>saas</category>
      <category>ai</category>
      <category>seo</category>
    </item>
    <item>
      <title>I shipped 5 things around my product in 90 minutes — MCP server, GitHub Action, 3 SEO landings</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 12 May 2026 05:44:56 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-shipped-5-things-around-my-product-in-90-minutes-mcp-server-github-action-3-seo-landings-34dh</link>
      <guid>https://dev.to/perufitlife/i-shipped-5-things-around-my-product-in-90-minutes-mcp-server-github-action-3-seo-landings-34dh</guid>
      <description>&lt;p&gt;I shipped &lt;a href="https://aitells.vercel.app" rel="noopener noreferrer"&gt;aitells&lt;/a&gt; (free AI text detector + paid humanizer) yesterday. Today I shipped 5 more things around it in about 90 minutes. Sharing because each is a small, reusable distribution play that compounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. MCP server (npm published, GitHub repo)
&lt;/h2&gt;

&lt;p&gt;→ &lt;a href="https://www.npmjs.com/package/@perufitlife/aitells-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;@perufitlife/aitells-mcp&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://github.com/Perufitlife/aitells-mcp" rel="noopener noreferrer"&gt;github.com/Perufitlife/aitells-mcp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Claude Code, Cursor, and any MCP-compatible client now get &lt;code&gt;detect_ai_tells&lt;/code&gt; and &lt;code&gt;humanize_text&lt;/code&gt; as native tools. Configure once, ask the model "humanize this LinkedIn post in my voice" and it picks the right tool.&lt;/p&gt;

&lt;p&gt;Implementation is 100 lines of TypeScript wrapping the existing aitells public API. The win isn't the code — it's that the MCP ecosystem has its own discovery channels (Glama.ai, awesome-mcp-servers, etc.) and a different audience than the web app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"aitells"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@perufitlife/aitells-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. GitHub Action (marketplace listing)
&lt;/h2&gt;

&lt;p&gt;→ &lt;a href="https://github.com/Perufitlife/aitells-action" rel="noopener noreferrer"&gt;github.com/Perufitlife/aitells-action&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Scans PR titles, bodies, and commit messages for em-dashes, "delve", parallel bullets, etc. Posts a friendly summary comment on every PR.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Perufitlife/aitells-action@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;both&lt;/span&gt;
    &lt;span class="na"&gt;fail-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;65&lt;/span&gt;    &lt;span class="c1"&gt;# block merge if humanness &amp;lt; 65&lt;/span&gt;
    &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same idea as the MCP server — wrap the existing API in a different package format to hit a different audience (engineering teams that care about keeping their PR history sounding human).&lt;/p&gt;

&lt;h2&gt;
  
  
  3-5. Three competitor SEO landings
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://aitells.vercel.app/vs-zerogpt" rel="noopener noreferrer"&gt;aitells.vercel.app/vs-zerogpt&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aitells.vercel.app/vs-quillbot" rel="noopener noreferrer"&gt;aitells.vercel.app/vs-quillbot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aitells.vercel.app/vs-undetectable-ai" rel="noopener noreferrer"&gt;aitells.vercel.app/vs-undetectable-ai&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one is a Next.js page with metadata + canonical URL + a shared &lt;code&gt;_vs/client.tsx&lt;/code&gt; component that does live detection on whatever text the user pastes. The differentiation argument is in the page copy. Templated, took ~30 min total for all three.&lt;/p&gt;

&lt;p&gt;People searching "zerogpt vs..." have buyer intent. That search has high commercial value for low effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  The meta pattern
&lt;/h2&gt;

&lt;p&gt;I had one product (aitells.vercel.app). I now have 6 entry points to it: the web app, MCP server, GitHub Action, and 3 SEO landings. Plus the existing Dev.to articles and GitHub profile README and cross-links from other repos.&lt;/p&gt;

&lt;p&gt;The actual code under all of these is the same &lt;code&gt;/api/detect&lt;/code&gt; and &lt;code&gt;/api/rewrite&lt;/code&gt; endpoints. Everything else is &lt;em&gt;packaging&lt;/em&gt; the same core for a different audience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building takes hours. Packaging takes 30 minutes per format.&lt;/strong&gt; If your product solves a real problem (and AI text leaking through review pipelines is a real problem), the bottleneck isn't building more features — it's getting the existing thing in front of the audiences that don't know your URL.&lt;/p&gt;

&lt;p&gt;I also just published the &lt;a href="https://aitells.vercel.app/api-docs" rel="noopener noreferrer"&gt;public API docs&lt;/a&gt; so anyone can integrate the raw endpoints in their own product. The detector is free forever. The humanizer is $19 lifetime, first 100 buyers only, then $49/mo.&lt;/p&gt;

&lt;p&gt;If you want to use it as an MCP server: &lt;code&gt;npm install -g @perufitlife/aitells-mcp&lt;/code&gt; and follow the config snippet above.&lt;/p&gt;




&lt;p&gt;I also build &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;supabase-security&lt;/a&gt; and sister BaaS auditors. Same pattern: build the tool, then package it 6 ways. The pattern works.&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>mcp</category>
      <category>githubactions</category>
      <category>seo</category>
    </item>
    <item>
      <title>I found 3 silent revenue-leaking bugs in my SaaS in 45 minutes (and the meta-lesson)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 12 May 2026 05:18:14 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-found-3-silent-revenue-leaking-bugs-in-my-saas-in-45-minutes-and-the-meta-lesson-24f8</link>
      <guid>https://dev.to/perufitlife/i-found-3-silent-revenue-leaking-bugs-in-my-saas-in-45-minutes-and-the-meta-lesson-24f8</guid>
      <description>&lt;p&gt;I run a small aviation SaaS ($45 MRR, 3 paying customers, 75% churn). This morning I decided to actually look at my data instead of write more code. In 45 minutes I found three silent revenue-leaking bugs. Sharing each one because they're the kind of stuff every indie SaaS has and never notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: cron silently returned 0 candidates for 6 weeks
&lt;/h2&gt;

&lt;p&gt;I had a winback cron (&lt;code&gt;/api/cron/winback&lt;/code&gt;) that emails canceled subscribers at day 7, 21, 45 after they cancel. The Supabase query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;subs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscriptions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`user_id, status, tier, current_period_end, updated_at, created_at, ...`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canceled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;updated_at&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Problem: the column is &lt;code&gt;canceled_at&lt;/code&gt;, not &lt;code&gt;updated_at&lt;/code&gt;. The Supabase JS client returned &lt;code&gt;{ error: 'column does not exist' }&lt;/code&gt;, the cron caught it and quietly returned an empty array. Every day for weeks, "0 candidates, 0 emails sent, exit 0". No alarm.&lt;/p&gt;

&lt;p&gt;I caught it because I ran &lt;code&gt;SELECT COUNT(*) WHERE winback_pulses_sent IS NOT NULL&lt;/code&gt; and got 0 across 51 premium-tagged profiles. Then I read the cron source.&lt;/p&gt;

&lt;p&gt;Lesson: anywhere your code does &lt;code&gt;if (error) return []&lt;/code&gt;, log the error first. Silent failure is the worst failure mode in marketing automation because users don't bounce — they just never hear from you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: webhook never wrote the timestamp it was supposed to
&lt;/h2&gt;

&lt;p&gt;Same &lt;code&gt;subscriptions&lt;/code&gt; table. 14 rows with &lt;code&gt;status='canceled'&lt;/code&gt; and &lt;code&gt;canceled_at = NULL&lt;/code&gt;. Stripe knew when each cancellation happened, my webhook just never stored it.&lt;/p&gt;

&lt;p&gt;Looking at the webhook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.subscription.deleted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscriptions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canceled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;// ← no canceled_at here&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// ... later, inside a try block ...&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// ... other side effects ...&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscriptions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;cancellation_reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cancelReason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;cancellation_feedback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cancelFeedback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;canceled_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// swallow&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;canceled_at&lt;/code&gt; was only set inside a &lt;code&gt;try&lt;/code&gt; block alongside other side effects. If any of the side effects failed (which it apparently did, since 14/15 rows had NULL), the whole try aborted and &lt;code&gt;canceled_at&lt;/code&gt; never got written. The first &lt;code&gt;.update()&lt;/code&gt; was the only one guaranteed to run, and it didn't include &lt;code&gt;canceled_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fix: move the timestamp to the unconditional update.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canceled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;canceled_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then backfilled 15 historical rows from Stripe's &lt;code&gt;canceled_at&lt;/code&gt; via the Stripe API + Supabase REST PATCH.&lt;/p&gt;

&lt;p&gt;This bug compounds the first one: even with the cron fixed, it would've found 0 candidates because the column it filtered on was NULL across all historical cancels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: filter that worked on day 1 broke as data accumulated
&lt;/h2&gt;

&lt;p&gt;Recovery cron for abandoned checkouts. Pulls Stripe sessions in &lt;code&gt;status='expired'&lt;/code&gt; from last 48h, then filters out anyone who "already paid". The filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;activeProfiles&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabaseAdmin&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;profiles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email, subscription_tier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;neq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscription_tier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Intent: "don't bother people who already converted". Implementation: filter by &lt;code&gt;subscription_tier&lt;/code&gt; column in &lt;code&gt;profiles&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reality: 51 profiles in my DB had &lt;code&gt;subscription_tier = 'premium'&lt;/code&gt; from past subscriptions that had since canceled. The webhook bug above meant their tier wasn't downgraded to &lt;code&gt;free&lt;/code&gt; when they canceled. So when a new abandoned-checkout user shared an email with one of those 51 zombie profiles, the cron silently skipped them.&lt;/p&gt;

&lt;p&gt;Result: cron output said &lt;code&gt;{"total_abandoned": 11, "sent": 0, "skipped": 11}&lt;/code&gt; every day. Looks like the system is working ("no abandoned checkouts to recover today"), is actually broken.&lt;/p&gt;

&lt;p&gt;Fix: derive the "already paid" set from &lt;code&gt;subscriptions.status='active'&lt;/code&gt;, not from &lt;code&gt;profiles.subscription_tier&lt;/code&gt;. The subscriptions table is what Stripe writes, the profiles tier was a denormalized convenience that drifted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped after finding all three
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Winback cron fix → 8 emails to real canceled customers immediately (1 of them at pulse 3 = 60% off final offer)&lt;/li&gt;
&lt;li&gt;Webhook fix → future cancels populate &lt;code&gt;canceled_at&lt;/code&gt; correctly&lt;/li&gt;
&lt;li&gt;Recovery cron fix → next run will pulse 11 abandoned-checkout emails in pipeline&lt;/li&gt;
&lt;li&gt;Manual final-shot to 5 maxed-out abandoned-checkout leads (60-cent customs in Resend)&lt;/li&gt;
&lt;li&gt;Backfilled 15 historical timestamps from Stripe API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total emails out from those 45 minutes: 14, all with &lt;code&gt;WINBACK60&lt;/code&gt; coupon (60% off 3 months). Worst case: nobody converts and I learned my data flow. Best case: even 2 conversions = +$30 MRR which is 66% growth on a $45 baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The meta-lesson
&lt;/h2&gt;

&lt;p&gt;I had been writing more features all week. Adding more cron jobs. More email templates. More variants. None of it would have helped because the actual pipes were leaking. &lt;strong&gt;Sometimes the highest-leverage 30 minutes you can spend is just SQL-querying your own database.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you run a small SaaS: try &lt;code&gt;SELECT status, COUNT(*) FROM subscriptions GROUP BY status&lt;/code&gt; and reconcile it against your Stripe dashboard. If those don't match, you have at least one of the three bugs I had.&lt;/p&gt;




&lt;p&gt;I also build &lt;a href="https://aitells.vercel.app" rel="noopener noreferrer"&gt;aitells.vercel.app&lt;/a&gt; (free AI text detector + paid humanizer) and &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;supabase-security&lt;/a&gt; (open source RLS auditor). Both built after I got bitten by the problem they solve. Same pattern.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>postgres</category>
      <category>debugging</category>
    </item>
    <item>
      <title>How I shipped the rewriter side of an AI tell detector in 30 minutes (Claude + Next.js + Vercel)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 12 May 2026 03:12:19 +0000</pubDate>
      <link>https://dev.to/perufitlife/how-i-shipped-the-rewriter-side-of-an-ai-tell-detector-in-30-minutes-claude-nextjs-vercel-13g0</link>
      <guid>https://dev.to/perufitlife/how-i-shipped-the-rewriter-side-of-an-ai-tell-detector-in-30-minutes-claude-nextjs-vercel-13g0</guid>
      <description>&lt;p&gt;Yesterday I shipped a free detector for the 12 most reliable AI writing fingerprints. The story behind it: my reddit account got 2 public "all comments are AI generated" callouts in one day. Mods removed 3 posts. I was running everything through Claude. The em-dashes and "delve" gave me away in seconds.&lt;/p&gt;

&lt;p&gt;Detector traction was fine. 100+ scans day one, sat at 12 page views from a Dev.to post overnight. But every single piece of feedback I got was the same: "ok cool, now how do I fix it without rewriting by hand every time?"&lt;/p&gt;

&lt;p&gt;So I shipped the rewriter today.&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;&lt;a href="https://aitells.vercel.app/rewrite" rel="noopener noreferrer"&gt;https://aitells.vercel.app/rewrite&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  what it does, technically
&lt;/h2&gt;

&lt;p&gt;You paste two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your AI-generated text (the thing you would post)&lt;/li&gt;
&lt;li&gt;1-3 writing samples of how you actually write (old reddit comments, tweets, emails)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The endpoint sends both to Claude with a strict system prompt that hard-bans em-dashes, "delve", "tapestry", "navigate the X", "in conclusion", "however,", parallel bullet structure, uniform sentence length, and 9 other patterns from the detector ruleset.&lt;/p&gt;

&lt;p&gt;The samples are critical. Without them you get generic "casual reddit voice" output which is fine but not yours. With them, you get sentence rhythm matched to how you actually type. Lowercase if you lowercase. Typos and fragments allowed if your samples have them. Slang preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  why it's different from "AI humanizer" tools
&lt;/h2&gt;

&lt;p&gt;Most of those just re-prompt GPT to "write more naturally". You end up with slightly different AI text. Same em-dashes, same "delve", same parallel bullets. They fail Reddit moderation the same way the original did.&lt;/p&gt;

&lt;p&gt;This one is built on top of the detector. The system prompt enumerates the exact patterns the detector flags. Output that still trips the detector defeats the purpose, so the constraints are explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  tech stack, since you asked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 14 App Router, edge route for the detector, nodejs runtime for the rewriter (Anthropic API was blocking edge IPs)&lt;/li&gt;
&lt;li&gt;Claude Sonnet 4.6 via the messages API&lt;/li&gt;
&lt;li&gt;No database. localStorage tracks the free-tier counter. Email gets pushed to Resend for the list.&lt;/li&gt;
&lt;li&gt;Stripe Payment Link for the $19 lifetime tier. No webhooks yet, manual fulfillment until volume justifies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is 3 files, deployed on Vercel hobby tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's broken / shipping next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No Stripe-gated unlimited path yet. After purchase I send the customer a permanent email token by hand. Will automate it once I see 5 sales.&lt;/li&gt;
&lt;li&gt;Detector and rewriter are decoupled. I want a single workflow: scan, see flags, click "rewrite this", get the output rescored. Shipping that this week.&lt;/li&gt;
&lt;li&gt;The detector rule set is closed for now. Will open-source once it stabilizes past v0.5.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  try it
&lt;/h2&gt;

&lt;p&gt;If you ship AI-generated reddit comments, cold emails, tweets, or LinkedIn posts and your engagement is mysteriously bad, paste your last 3 outputs into the detector. The score is real and the highlighted patterns are reproducible.&lt;/p&gt;

&lt;p&gt;If they all score below 60, you need the rewriter.&lt;/p&gt;

&lt;p&gt;aitells.vercel.app/rewrite&lt;/p&gt;

&lt;p&gt;Free first rewrite. $19 lifetime if you want it forever. First 100 buyers only, then it goes to $49/mo.&lt;/p&gt;




&lt;p&gt;Built by &lt;a href="https://github.com/Perufitlife" rel="noopener noreferrer"&gt;@Perufitlife&lt;/a&gt;. Also shipped a &lt;a href="https://perufitlife.github.io/supabase-security-skill/" rel="noopener noreferrer"&gt;Supabase security auditor&lt;/a&gt; recently after finding 14 critical leaks in my own CRM. Same pattern: build the thing you wish existed when you got bitten.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nextjs</category>
      <category>claude</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>I built a free AI tell detector after my own Reddit account got 2 'all comments are AI generated' callouts in one day</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 12 May 2026 02:51:48 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-built-a-free-ai-tell-detector-after-my-own-reddit-account-got-2-all-comments-are-ai-generated-57ei</link>
      <guid>https://dev.to/perufitlife/i-built-a-free-ai-tell-detector-after-my-own-reddit-account-got-2-all-comments-are-ai-generated-57ei</guid>
      <description>&lt;p&gt;In 24 hours my Reddit account picked up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 ModTeam removals&lt;/li&gt;
&lt;li&gt;2 public callouts of "all comments are AI generated"&lt;/li&gt;
&lt;li&gt;1 "Calm down with your AI responses. Your credibility goes down hard if you can't formulate sentences on your own"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was running everything through Claude. Clean writing. The mods spotted it in seconds.&lt;/p&gt;

&lt;p&gt;So I sat down and mapped the patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  the 12 most reliable AI tells
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;em-dash (â€”)&lt;/strong&gt;. The single strongest tell. Real people on Reddit use commas and periods. AI loves the em-dash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"delve"&lt;/strong&gt;. Just don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tapestry / realm / landscape / journey / venture / endeavor&lt;/strong&gt;. Metaphor cluster nobody actually types.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"navigate the X", "unlock the X", "harness the X"&lt;/strong&gt;. Verb-noun combos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Great question"&lt;/strong&gt;, "Absolutely", "100%", "Exactly this" as standalone openers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"In conclusion", "In summary", "Ultimately,"&lt;/strong&gt;. Essay closers in casual writing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;buzzword cluster&lt;/strong&gt;: leverage, robust, seamless, holistic, streamline, ecosystem. One is fine. Three in a paragraph reads AI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;parallel bullet structure&lt;/strong&gt;. All bullets the same length, all starting with the same verb form. Humans write uneven lists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tricolon rhythm&lt;/strong&gt;: "X, Y, and Z" lists repeated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;uniform sentence length&lt;/strong&gt;. Humans write some short, some long. AI averages out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Title Case headings&lt;/strong&gt; in informal comments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"However,", "Moreover,", "Furthermore,"&lt;/strong&gt; sentence openers. Humans use "but" and "also".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I put all of this behind a free in-browser detector. Paste your AI text, see every flag highlighted, fix before posting.&lt;/p&gt;

&lt;p&gt;â†’ &lt;strong&gt;&lt;a href="https://aitells.vercel.app/" rel="noopener noreferrer"&gt;https://aitells.vercel.app/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No login, no auth.&lt;/p&gt;

&lt;h2&gt;
  
  
  update: the rewriter just shipped
&lt;/h2&gt;

&lt;p&gt;Detection is half the problem. After 100+ scans on day one, the question I kept getting was: "ok now how do I fix it without rewriting everything by hand?"&lt;/p&gt;

&lt;p&gt;So I shipped the rewriter today.&lt;/p&gt;

&lt;p&gt;→ &lt;strong&gt;&lt;a href="https://aitells.vercel.app/rewrite" rel="noopener noreferrer"&gt;https://aitells.vercel.app/rewrite&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;How it works: paste your AI text, paste 1-3 samples of how you actually write (old reddit comments, tweets, emails), and it strips the fingerprints while matching your voice. No em-dashes ever. Bullet symmetry broken. Varied sentence length. Lowercase if you lowercase.&lt;/p&gt;

&lt;p&gt;Most "AI humanizer" tools just re-prompt GPT to "write more naturally" which still produces AI text. This one runs the same 12-rule detector against the output. If your samples are real, the rewrite reads like you.&lt;/p&gt;

&lt;p&gt;First rewrite is free with your email. $19 lifetime if you want unlimited.&lt;/p&gt;

&lt;h2&gt;
  
  
  why this might help
&lt;/h2&gt;

&lt;p&gt;If you ship Reddit comments or cold emails written by AI and your conversion is mysteriously bad, this is probably part of it. Detection is more aggressive every month. The fix isn't "stop using AI to write" - the fix is removing the fingerprints.&lt;/p&gt;

&lt;p&gt;I'm shipping more rules daily. Open to suggestions if you have a tell I missed.&lt;/p&gt;

&lt;p&gt;Source for the detection rules: &lt;a href="https://github.com/Perufitlife" rel="noopener noreferrer"&gt;https://github.com/Perufitlife&lt;/a&gt; (will open-source the rule set once it stabilizes).&lt;/p&gt;




&lt;p&gt;Also built a Supabase security auditor recently after finding 14 critical leaks in my own CRM: &lt;a href="https://perufitlife.github.io/supabase-security-skill/" rel="noopener noreferrer"&gt;https://perufitlife.github.io/supabase-security-skill/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same pattern: build the thing you wish existed when you got bitten.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>showdev</category>
      <category>writing</category>
    </item>
    <item>
      <title>I shipped 5 BaaS security auditors in one day — keyless `npx --discover` mode for Supabase, PocketBase, Appwrite, Firebase, and Nhost</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Mon, 11 May 2026 12:33:02 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-shipped-5-baas-security-auditors-in-one-day-keyless-npx-discover-mode-for-supabase-334f</link>
      <guid>https://dev.to/perufitlife/i-shipped-5-baas-security-auditors-in-one-day-keyless-npx-discover-mode-for-supabase-334f</guid>
      <description>&lt;h2&gt;
  
  
  The story
&lt;/h2&gt;

&lt;p&gt;A week ago I built &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;supabase-security&lt;/a&gt;, a small Node.js auditor that scans Supabase projects for over-permissive RLS policies. To test it, I scanned 100 random Supabase projects from GitHub.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;22 out of 100 leaked user data anonymously.&lt;/strong&gt; The pattern was consistent: dashboard says "RLS enabled ✅", policies say &lt;code&gt;USING (true)&lt;/code&gt;, anonymous &lt;code&gt;curl&lt;/code&gt; returns the full table.&lt;/p&gt;

&lt;p&gt;Then I ran the tool against my own production CRM (FitCRM, an e-commerce ops platform I've been running for 2 years). Found &lt;strong&gt;14 critical leaks&lt;/strong&gt;. Order tables open to anon. Storage buckets with predictable signed URLs. RPCs with SECURITY DEFINER bypassing RLS.&lt;/p&gt;

&lt;p&gt;I wrote &lt;a href="https://dev.to/perufitlife/i-built-a-supabase-security-tool-then-found-14-critical-leaks-in-my-own-production-crm-4gd4"&gt;the postmortem&lt;/a&gt; yesterday.&lt;/p&gt;

&lt;p&gt;After that, two thoughts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If this many random Supabase projects leak, what about every other BaaS?&lt;/li&gt;
&lt;li&gt;The "keyless" mode (parse repo + probe anon, no admin creds) is the magic — anyone can run it on any project, including ones they don't own.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I shipped today
&lt;/h2&gt;

&lt;p&gt;Five sister tools. All MIT, all on npm, all use the same &lt;code&gt;--discover&lt;/code&gt; pattern:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. supabase-security v0.4
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx supabase-security@latest &lt;span class="nt"&gt;--discover&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parses &lt;code&gt;from('table')&lt;/code&gt; / &lt;code&gt;rpc('function')&lt;/code&gt; / &lt;code&gt;storage.from('bucket')&lt;/code&gt; call sites, extracts &lt;code&gt;SUPABASE_URL&lt;/code&gt; + &lt;code&gt;SUPABASE_ANON_KEY&lt;/code&gt; from your repo, then probes the public REST API anonymously. Flags any table that returns rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. pocketbase-security v0.2
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx pocketbase-security &lt;span class="nt"&gt;--discover&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parses &lt;code&gt;pb.collection('name')&lt;/code&gt; call sites, then hits &lt;code&gt;/api/collections/{name}/records&lt;/code&gt; anon. Catches the legendary PocketBase footguns: empty rules (= fully public), &lt;code&gt;@request.auth.id != ""&lt;/code&gt; (any signed-up user passes), &lt;code&gt;|| true&lt;/code&gt; leftover dev rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. appwrite-security v0.2
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx appwrite-security &lt;span class="nt"&gt;--discover&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parses &lt;code&gt;databases.listDocuments(dbId, collId)&lt;/code&gt;, probes &lt;code&gt;/v1/databases/{db}/collections/{coll}/documents&lt;/code&gt; anon. Flags collections with the &lt;code&gt;any&lt;/code&gt; role on Read permission, or &lt;code&gt;users&lt;/code&gt; role with document security OFF.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. firebase-security v0.2
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx firebase-security &lt;span class="nt"&gt;--discover&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parses &lt;code&gt;collection(db, 'x')&lt;/code&gt; / &lt;code&gt;doc(db, 'x/y')&lt;/code&gt;, extracts &lt;code&gt;projectId&lt;/code&gt; from firebase config or env, then GETs &lt;code&gt;firestore.googleapis.com/v1/projects/{pid}/databases/(default)/documents/{collection}&lt;/code&gt; anon. Confirms the legendary &lt;code&gt;if true&lt;/code&gt; / wildcard-match-all leak pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. nhost-security v0.2
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx nhost-security &lt;span class="nt"&gt;--discover&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parses &lt;code&gt;gql&lt;/code&gt;`&lt;code&gt;queries for table names, auto-resolves the Hasura endpoint from subdomain+region (or env), POSTs anonymous queries against&lt;/code&gt;/v1/graphql&lt;code&gt;. Flags tables where the&lt;/code&gt;anonymous` role has SELECT with permissive row filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The common pattern across every BaaS
&lt;/h2&gt;

&lt;p&gt;Every BaaS has the same trap:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;BaaS&lt;/th&gt;
&lt;th&gt;Footgun&lt;/th&gt;
&lt;th&gt;Dashboard says&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;USING (true)&lt;/code&gt; on RLS policy&lt;/td&gt;
&lt;td&gt;"RLS enabled ✅"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PocketBase&lt;/td&gt;
&lt;td&gt;Empty rule string&lt;/td&gt;
&lt;td&gt;"Public" (in tiny text)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appwrite&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;any&lt;/code&gt; role on Read&lt;/td&gt;
&lt;td&gt;Permissions "configured"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firebase&lt;/td&gt;
&lt;td&gt;&lt;code&gt;allow read: if true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Test mode"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nhost&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;anonymous&lt;/code&gt; role with &lt;code&gt;{}&lt;/code&gt; filter&lt;/td&gt;
&lt;td&gt;"Permission configured"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In every case, the dashboard makes the configuration look intentional. In every case, anonymous &lt;code&gt;curl&lt;/code&gt; returns rows. The dashboards don't show you what an anonymous-role evaluation actually returns — only what your role (the developer, signed in) returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "keyless --discover" is the unlock
&lt;/h2&gt;

&lt;p&gt;Traditional security audits need admin credentials. The dev says "audit my Supabase" and hands you a service-role key. That's a high-trust ask and creates friction.&lt;/p&gt;

&lt;p&gt;The keyless mode flips it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run &lt;code&gt;npx &amp;lt;stack&amp;gt;-security --discover .&lt;/code&gt; from any project directory&lt;/li&gt;
&lt;li&gt;It reads your client code (which already knows what tables/collections/buckets exist)&lt;/li&gt;
&lt;li&gt;It probes the public API with the same anonymous credentials anyone on the internet has&lt;/li&gt;
&lt;li&gt;No admin auth, no key sharing, no install&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can run it against your own project before pushing, or against a friend's project they're worried about, or in a CI step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patterns I'd add next
&lt;/h2&gt;

&lt;p&gt;Things I want to detect but don't yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform&lt;/strong&gt;: Same project using Supabase + Stripe + Resend — credential leak in one breaks all three. Need a unified scanner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT signature secrets&lt;/strong&gt; committed to repos (already detectable via gitleaks but I want the BaaS-specific impact analysis)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage paths with predictable signed URLs&lt;/strong&gt; — the FitCRM case&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Function call sites with SECURITY DEFINER bypassing RLS&lt;/strong&gt; — PostgREST anti-pattern&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GraphQL introspection&lt;/strong&gt; on Hasura instances exposing schema&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Pick the stack you use most. The discover mode runs in seconds:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`bash&lt;br&gt;
cd your-project&lt;br&gt;
npx supabase-security --discover .     # or pocketbase-security, etc&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If you have a project worth a deeper audit, the &lt;a href="https://buy.stripe.com/8x2bIT8tw37PgcocQU0VG02" rel="noopener noreferrer"&gt;paid $99 single-tenant audit&lt;/a&gt; report includes the manual review I did on my own CRM that found the SECURITY DEFINER RPCs and the predictable bucket paths. But the free CLI catches the common 70% of leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;supabase-security: &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;github&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/supabase-security" rel="noopener noreferrer"&gt;npm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;pocketbase-security: &lt;a href="https://github.com/Perufitlife/pocketbase-security-skill" rel="noopener noreferrer"&gt;github&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/pocketbase-security" rel="noopener noreferrer"&gt;npm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;appwrite-security: &lt;a href="https://github.com/Perufitlife/appwrite-security-skill" rel="noopener noreferrer"&gt;github&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/appwrite-security" rel="noopener noreferrer"&gt;npm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;firebase-security: &lt;a href="https://github.com/Perufitlife/firebase-security-skill" rel="noopener noreferrer"&gt;github&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/firebase-security" rel="noopener noreferrer"&gt;npm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nhost-security: &lt;a href="https://github.com/Perufitlife/nhost-security-skill" rel="noopener noreferrer"&gt;github&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/nhost-security" rel="noopener noreferrer"&gt;npm&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All MIT. If you find issues, open a PR. If you find a leak in your own project — fix it before someone else does.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you run &lt;code&gt;--discover&lt;/code&gt; on your project and want a second pair of eyes on the findings, I do one-off paid audits (&lt;a href="https://buy.stripe.com/8x2bIT8tw37PgcocQU0VG02" rel="noopener noreferrer"&gt;$99 single project&lt;/a&gt;, &lt;a href="https://buy.stripe.com/9B68wH9xAaAhfYk84E0VG03" rel="noopener noreferrer"&gt;$249 multi-tenant&lt;/a&gt;). But honestly, the CLI catches most of it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>security</category>
      <category>showdev</category>
      <category>tooling</category>
    </item>
    <item>
      <title>I built a Supabase security tool, then found 14 critical leaks in my own production CRM</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Mon, 11 May 2026 11:29:47 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-built-a-supabase-security-tool-then-found-14-critical-leaks-in-my-own-production-crm-4gd4</link>
      <guid>https://dev.to/perufitlife/i-built-a-supabase-security-tool-then-found-14-critical-leaks-in-my-own-production-crm-4gd4</guid>
      <description>&lt;p&gt;I've been working on an open-source security auditor for Supabase projects for the last few weeks (&lt;a href="https://www.npmjs.com/package/supabase-security" rel="noopener noreferrer"&gt;&lt;code&gt;supabase-security&lt;/code&gt; on npm&lt;/a&gt;, MIT licensed, ~4k weekly downloads). Probes anonymously to find RLS gaps, public storage buckets, SECURITY DEFINER functions exposed to anon, that kind of thing.&lt;/p&gt;

&lt;p&gt;This morning I shipped a new feature: a &lt;code&gt;--discover&lt;/code&gt; mode. Keyless — it walks your local repo, pulls every table / RPC / bucket reference from &lt;code&gt;.from()&lt;/code&gt; / &lt;code&gt;.rpc()&lt;/code&gt; / &lt;code&gt;.storage.from()&lt;/code&gt; call sites, then probes only the surface your app actually uses with the public anon key. No PAT, no admin token, no signup.&lt;/p&gt;

&lt;p&gt;Before announcing it, I wanted to run it against my own production CRM as the final QA test. Worst case it finds zero issues and I look smart.&lt;/p&gt;

&lt;h2&gt;
  
  
  It found 14 critical leaks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;7 tables&lt;/strong&gt; that returned actual rows to anonymous callers (&lt;code&gt;couriers&lt;/code&gt;, &lt;code&gt;bot_knowledge_base&lt;/code&gt;, &lt;code&gt;product_variants&lt;/code&gt;, &lt;code&gt;zone_stats&lt;/code&gt;, etc — internal business data)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 SECURITY DEFINER functions&lt;/strong&gt; that anyone with my public anon key could execute (&lt;code&gt;get_dashboard_stats&lt;/code&gt;, &lt;code&gt;get_courier_stats&lt;/code&gt;, &lt;code&gt;get_historical_daily_stats&lt;/code&gt; — basically the entire reporting surface, all callable without auth)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 storage buckets&lt;/strong&gt; that anon could list&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Every single one had RLS technically "enabled" on the Supabase dashboard. Every SELECT policy was &lt;code&gt;USING (true)&lt;/code&gt; applied to roles &lt;code&gt;{-}&lt;/code&gt; (which in Postgres means "all roles", anon included).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What I found via pg_policy:&lt;/span&gt;
&lt;span class="n"&gt;polname&lt;/span&gt;                          &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;polcmd&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;using_expr&lt;/span&gt;
&lt;span class="n"&gt;couriers_select&lt;/span&gt;                  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;bot_knowledge_base_select&lt;/span&gt;        &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;product_variants_select&lt;/span&gt;          &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;      &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="c1"&gt;-- ...and 4 more&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server-side reads use &lt;code&gt;service_role&lt;/code&gt; (which bypasses RLS), so nothing internal broke. Client-side reads were never wired up either. The only callers were... anyone on the internet, since the anon key sits in every JS bundle the moment someone opens devtools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;couriers_select&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;couriers_select&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;couriers&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- repeat 6x&lt;/span&gt;

&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_dashboard_stats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt;  &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_dashboard_stats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- repeat 5x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applied via the Management API since local migrations were out of sync with prod. Re-ran the audit. 14 → 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I keep thinking about
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The dashboard "RLS enabled" checkmark is misleading.&lt;/strong&gt; It tells you nothing about who can actually read what. Until you fire an HTTP request with the public anon key and inspect the response, you're guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tools that don't catch yourself aren't done.&lt;/strong&gt; I had written the check for this exact pattern six weeks ago, and was still vulnerable. Static analysis of migrations alone misses it — the SQL looks fine. You need to actually probe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The under-priced risk on Supabase right now is RPC functions&lt;/strong&gt; with &lt;code&gt;SECURITY DEFINER&lt;/code&gt; + &lt;code&gt;EXECUTE&lt;/code&gt; granted to &lt;code&gt;anon&lt;/code&gt;. Most scanners don't look at the RPC surface. Mine had 6 open. Several leaked admin-flavored data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on yours
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx supabase-security@latest &lt;span class="nt"&gt;--discover&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MIT licensed, findings stay on your machine, no signup. &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;Repo on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;First time the tool felt like it was actually pulling its weight was when it pointed back at me. Mildly humbling.&lt;/p&gt;

&lt;p&gt;If you ship on Supabase, please run this. The 22% figure I quote in &lt;a href="https://perufitlife.github.io/supabase-security-skill/blog/scanned-100-supabase-projects.html" rel="noopener noreferrer"&gt;the longer blog post about scanning 100 random projects&lt;/a&gt; is real, and I now know first-hand that "I'm careful with RLS" isn't a defense.&lt;/p&gt;

</description>
      <category>database</category>
      <category>opensource</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I scanned 35 random Firebase projects from GitHub. 23% leak user data anonymously.</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Sat, 09 May 2026 15:29:39 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-scanned-35-random-firebase-projects-from-github-23-leak-user-data-anonymously-14ej</link>
      <guid>https://dev.to/perufitlife/i-scanned-35-random-firebase-projects-from-github-23-leak-user-data-anonymously-14ej</guid>
      <description>&lt;p&gt;I picked 35 random Firebase project IDs from public GitHub repos this morning and probed each for publicly readable Firestore collections. No auth, no special tools — just plain &lt;code&gt;GET&lt;/code&gt; requests to &lt;code&gt;firestore.googleapis.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8 of them — 23% — returned data to an anonymous request.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the raw breakdown:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Collection&lt;/th&gt;
&lt;th&gt;# of projects leaking&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;products&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;posts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;profiles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orders&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;12 leaks across 8 projects&lt;/strong&gt; in a 35-sample slice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How is this possible?
&lt;/h2&gt;

&lt;p&gt;Firebase apps bundle a &lt;code&gt;firebase-config.js&lt;/code&gt; into every web build. That config contains the project ID. The project ID is &lt;strong&gt;not secret&lt;/strong&gt; — it's in the URL, in the JS bundle, in any &lt;code&gt;.env.example&lt;/code&gt; somewhere. The actual security boundary is your &lt;code&gt;firestore.rules&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;If your rules look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="nx"&gt;cloud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firestore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;databases&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="sr"&gt;/documents &lt;/span&gt;&lt;span class="err"&gt;{
&lt;/span&gt;    &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="o"&gt;=**&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…anyone in the world with your project ID can read every document in every collection. Test-mode rules ship with this exact pattern; they're meant to be temporary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The classic mistakes I see again and again
&lt;/h2&gt;

&lt;p&gt;After running this scan + reading hundreds of &lt;code&gt;firestore.rules&lt;/code&gt; files in the wild:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Test-mode never replaced&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="o"&gt;=**&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2099&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Someone wanted "test mode but not 30 days," changed the date to 2099, forgot about it. Three years later, still wide open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Auth-only without ownership&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means &lt;strong&gt;any logged-in user can read or write any other user's document.&lt;/strong&gt; Sign up with a throwaway account → read everyone's data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Read-open, write-protected&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Innocent for product catalogs, fatal for &lt;code&gt;/payments&lt;/code&gt; or &lt;code&gt;/users&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Wildcard match with no terminator&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;tenants&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="sr"&gt;/{document=**} &lt;/span&gt;&lt;span class="err"&gt;{
&lt;/span&gt;  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;tid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. But if the token is forged or &lt;code&gt;tenant_id&lt;/code&gt; claim is missing, the rule grants access. Most apps don't validate the claim is present.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to scan your own project
&lt;/h2&gt;

&lt;p&gt;I built a free in-browser scanner at &lt;strong&gt;&lt;a href="https://perufitlife.github.io/firebase-security-skill/scan.html" rel="noopener noreferrer"&gt;perufitlife.github.io/firebase-security-skill/scan.html&lt;/a&gt;&lt;/strong&gt; — paste your project ID, watch it probe a list of common collection names. The scan runs in your browser; nothing is sent to my server.&lt;/p&gt;

&lt;p&gt;If you'd rather scan from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx firebase-security ./firestore.rules &lt;span class="nt"&gt;--project-id&lt;/span&gt; YOUR_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…or from Apify (no install): &lt;strong&gt;&lt;a href="https://apify.com/renzomacar/firebase-security-auditor" rel="noopener noreferrer"&gt;apify.com/renzomacar/firebase-security-auditor&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Source code is MIT: &lt;a href="https://github.com/Perufitlife/firebase-security-skill" rel="noopener noreferrer"&gt;github.com/Perufitlife/firebase-security-skill&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on disclosure
&lt;/h2&gt;

&lt;p&gt;I'm not naming any of the 8 projects here. The aggregate stat is the point — the same scan against any random sample of public Firebase configs will produce a similar leak rate. If you'd like me to re-run with names privately so you can verify against your own (or check if you're in the original sample), email me: &lt;strong&gt;&lt;a href="mailto:renzomacar@gmail.com"&gt;renzomacar@gmail.com&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "fixing it" looks like
&lt;/h2&gt;

&lt;p&gt;For most leaks, the change is one block. Replace &lt;code&gt;if true&lt;/code&gt; with the appropriate auth + ownership check for your data model. Example for a &lt;code&gt;/users/{uid}&lt;/code&gt; collection where each user owns their own doc:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The auditor I built generates one of these snippets per finding, paste-ready.&lt;/p&gt;

&lt;p&gt;If you find leaks in your project and want a written audit report (PDF) with all the snippets bundled + 30 days of follow-up Q&amp;amp;A, I do a $29 lite tier (top 3 fixes) and a $99 full audit (every match block, 24h delivery): &lt;strong&gt;&lt;a href="https://perufitlife.github.io/firebase-security-skill/" rel="noopener noreferrer"&gt;perufitlife.github.io/firebase-security-skill&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But please run the free scan first. Most projects find their own leaks in the report.&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
