FIX-03 · LOVABLE · SUPABASE · RLS

Customers paid but stayed locked out of your Lovable app

Stripe shows the payment. The webhook even returns 200. And the customer is still staring at a paywall. Here's where the chain breaks after delivery — and how to see it.

Updated July 2026 · 5 min read · free self-serve checks inside

This is the crueler cousin of the missing webhook. The charge succeeded, the webhook endpoint exists, Stripe's delivery log shows a green 200 — and the customer who just paid you is still locked out. Everything looks healthy. The break is in the last step: the database write, or the check your app runs against it.

First, confirm you're in this case

In Stripe (Workbench → Webhooks → your endpoint), find the delivery for the customer's payment and confirm it returned 200. If it didn't, start with the webhook checklist instead. Then open Supabase → Table Editor, find the table your app uses to decide access — profiles, subscriptions, orders, names vary — and look at that customer's row. Delivered, but the row never changed? You're in the right article.

If you're comfortable in the SQL editor, this read-only query lists the most recently touched rows — it changes nothing (swap in your own table and column names):

Supabase → SQL Editor (read-only)
select id, email, subscription_status, updated_at
from profiles
order by updated_at desc
limit 10;

Cause 1: RLS policies silently swallowing the write

Supabase tables are usually protected by RLS policies — per-row rules about who may read or write what. Your webhook function isn't a logged-in user, so if it connects with the public (anon) key, those rules treat it as a stranger. The nasty part: a blocked UPDATE doesn't necessarily error. It can simply match zero rows and report success. The function returns 200, Stripe is satisfied, and nothing was written anywhere.

The fix is that the webhook must use the service role key — Supabase's server-side key that RLS policies don't apply to — and that key must never appear in frontend code. In generated apps we regularly find the anon key inside the webhook (writes silently fail), or occasionally the service key in the browser (rotate it in Supabase if you find that). Check which key your function reads from its environment: it should be a server-side secret like SUPABASE_SERVICE_ROLE_KEY, not the same key your frontend uses.

Cause 2: the webhook writes one thing, the app checks another

Two pieces of generated code, written at different moments, disagree about what "paid" means. The webhook sets is_paid = true; the paywall checks subscription_status = 'active'. Or the webhook writes to a subscriptions table while the app reads profiles. Each half is individually correct, and the product is broken.

How to spot it: in Table Editor, look at the paying customer's row after a delivered webhook. If something did change — a column flipped, a row appeared — but access still doesn't work, the write is fine and the check is reading the wrong field. This one is pure detective work: find the code that gates the locked page and see which column it actually reads.

Cause 3: the customer can't be matched

The webhook has to answer one question: which user just paid? Generated handlers usually match by email — and Stripe Checkout happily accepts an email different from the one the customer signed up with. Typed a work email at checkout, signed up with Gmail: the payment lands, no row matches, nothing updates. Capitalization differences and guest checkouts cause the same miss.

The robust pattern is to pass your app's own user ID into checkout — Stripe's client_reference_id field or session metadata — so the webhook never has to guess. If your handler matches by email, this failure will keep happening at a low, maddening rate, forever: it's the classic "works for most customers, breaks for a few every week" bug.

What you can fix yourself today

The unlucky-email case has an immediate manual fix: find the payment in Stripe, note the email, find the actual user in Supabase, and flip their access column by hand in Table Editor. Do the same for anyone who paid while things were broken. Customers care that it's fixed within the hour far more than they care how.

The structural fixes — service-role key in the webhook, one agreed source of truth for "paid", ID-based matching instead of email — mean editing and redeploying the function on a live app. Very doable if you're comfortable in the code. If you're not, that's the exact shape of job we take, and the diagnosis costs nothing.

STILL BROKEN? THAT'S THE HARD VERSION

Diagnosed free. Fixed for a flat $250. Proven live.

01 · FREE HEALTH CHECK

Describe the symptom in two minutes. You get a plain-English root cause within hours — yours to keep even if you fix it yourself. No call, no strings.

02 · THE $250 FLAT FIX

One broken flow, fixed within 48 hours of access — proven with a recorded live test transaction you watch, and covered by a 30-day warranty. Miss the deadline, pay nothing.

QUICK ANSWERS

?The webhook returns 200 — how can it still be broken?

200 only means the function finished without crashing. If the database write inside it was blocked by RLS policies, or its WHERE clause matched zero rows, the function still returns 200. Delivered is not the same as written.

?What are RLS policies and why do they block my webhook?

RLS policies are Supabase's per-row access rules. A webhook isn't a logged-in user, so writes made with the public anon key can be filtered down to zero rows without any error. Server-side code like a webhook should use the service role key, kept as a server-side secret.

?Why are only some customers affected?

That's the signature of email matching. Customers who pay with exactly the email they signed up with get access; anyone who used a different address, capitalization, or a guest checkout doesn't. Matching on your own user ID via client_reference_id removes the guesswork.