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
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
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.
PostgreSQL RLS beats application-level auth checks. Define the policy once, enforced on every query, impossible to bypass from the frontend.
Client-side validation is UX, not security. My old isPro JavaScript variable was 2 seconds away from being bypassed. Lesson learned the hard way.
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.
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.