1
0 Comments

I shipped a freemium SaaS with a paywall that wasn't a paywall

A few months ago I launched SkillVault (skillvault.fr), a French freemium platform for digital certification prep (Google Ads, Meta Blueprint, AWS, HubSpot, Salesforce, etc.).

Yesterday I discovered my "paywall" was decorative. Three clicks in the browser dev tools and anyone could access all the Pro content for free.

Here's what was wrong, and how I fixed it in 5 phases over 2 days.

The damage

  • 120 QCM answer explanations sitting in plain HTML
  • Fake auth with prompt() accepting any email
  • isPro = true could be set from the browser console
  • "Free questions used" counter in localStorage (resettable)
  • No server-side check anywhere

My paying customers were paying for content available to everyone for free.

Phase 1: Supabase table with Row-Level Security

Created a qcm_questions table with an RLS policy that only allows reads for users where profiles.plan IN ('pro_monthly', 'pro_lifetime') AND the plan hasn't expired. The DB itself enforces the rule, no application code needed.

Phase 2: Extract and migrate

Python script that parses 12 HTML files, extracts the 120 answer explanations, uploads to Supabase via REST API with the service_role key. Idempotent, dry-run mode first.

Phase 3: Real authentication

Built a centralized auth.js module. Magic Link via Supabase (real verified emails), JWT session, plan check from DB, automatic expiry handling. No more prompt() faking accounts.

Phase 4: Runtime hydration

Pro users: explanations fetched from Supabase on page load (1 request per session, cached). Non-Pro users: shown a "Pro lock" message instead. Optimization: skip the request entirely for non-Pro to save Supabase quota.

Phase 5: The moment of truth

Python script that strips all explanations from HTML. Before: 27 KB of premium content visible via Ctrl+U. After: zero. The paywall finally became real.

The acid test

Incognito window, Ctrl+U, search "explain:". Before: 120 instances with full content. After: 120 empty strings.

Same logic applied to flashcards

While I was at it, I migrated all 144 flashcards to Pro-only. No card data in HTML at all. Non-Pro users see a "Premium content" wall, Pro users get them rendered from Supabase.

Lessons learned

  1. Pragmatic > perfect. I kept the correct answer index (0-3) in HTML so free users can still see if they got it right. I only moved the explanation (the actual pedagogical value) to Supabase. Real attackers can know the right answer, but not why it's right. The "why" is what users pay for.

  2. PostgreSQL RLS beats application-level auth checks. Define the policy once, enforced on every query, impossible to bypass from the frontend.

  3. Client-side validation is UX, not security. My old isPro JavaScript variable was 2 seconds away from being bypassed. Lesson learned the hard way.

  4. Idempotent migration scripts save your life. Every patch script I wrote can be re-run with zero side effects. No fear of partial deploys, no fear of running twice by mistake.

  5. The 5-minute test that should be mandatory: Open your product in incognito. Hit Ctrl+U. Search for any keyword that should be "premium-only". If you find it, you have my old problem.

Tech stack

Static HTML/CSS/JS on a 4€/month Hetzner VPS. Supabase free tier for auth + DB. Stripe for payments. n8n for webhooks. Total infra cost: under 10€/month.

The awkward part: my paying customers were paying for fully exposed content. I'm now wondering how many indie SaaS out there have the same problem and don't know it.

If you want to peek at the result: skillvault.fr (French only for now). Happy to share any of the Python migration scripts if useful.

on May 11, 2026
Trending on Indie Hackers
I Was Picking the Wrong SaaS Tools for Two Years. Here's the Mistake I Finally Figured Out. User Avatar 115 comments Drop your landing page URL. I'll use Ferguson to tell you why visitors might be leaving User Avatar 66 comments Most early-stage SaaS companies miss churn signals — here’s how to catch them early User Avatar 31 comments Why Remote Teams Stop Talking (And Don't Even Notice It) User Avatar 23 comments How I Run a 1.7M Product Search Engine at 66ms on a $0 Hosting Budget User Avatar 19 comments I thought picking a voice for my app would take a day. It rebuilt everything. User Avatar 18 comments