One second. You're premium.
Open your app in the browser. Hit F12. Paste this in the console:
await supabase.from('profiles').update({ is_paid: true }).eq('id', (await supabase.auth.getUser()).data.user.id);
Hit enter. Reload. Check your account. Premium tier unlocked.
The Supabase client was already loaded. Your anon key was already in the page source. The RLS policy on profiles just checked auth.uid() = id — anyone can modify their own row, any column. So you just updated your own subscription field, and the database accepted it. The Stripe webhook never got involved. Neither did your backend. The user went from free to premium in two keystrokes.
This works on any app where three things are true: the profiles table has a subscription column like is_paid, the Supabase client is initialized in the browser (it always is), and the RLS policy on UPDATE doesn't restrict which columns can change. That combination is shockingly common. It's the default path AI code generators take when you ask for a subscription feature.
The attack is so frictionless because Supabase is supposed to work this way — the anon key is public, and the security model depends entirely on RLS policies. But most RLS policies written by Lovable, Cursor, and v0 are permissive. They say "you can update your own row" without saying "you can only update these columns." So they accidentally create two paths to premium: the correct one through Stripe, and a shortcut through Supabase.
How the pattern gets built
You prompt Lovable or Cursor: "Add a premium tier to my app." The AI needs somewhere to store who's paid. So it does the simplest thing that works.
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
email TEXT,
display_name TEXT,
avatar_url TEXT,
is_paid BOOLEAN DEFAULT false,
subscription_tier TEXT DEFAULT 'free',
credits INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT now()
);
Eight columns. Four of them (is_paid, subscription_tier, credits, created_at) should never be writable by the user. But the AI treats them all the same — just fields in a row.
Then it generates an RLS policy so the app actually works:
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own profile"
ON profiles FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE USING (auth.uid() = id);
That UPDATE policy says: you can modify any column in your own row. It doesn't distinguish between display_name (which you should edit) and is_paid (which only Stripe should touch). From the AI's perspective, the feature is done. The premium gate works in the UI. Users see different content based on their tier.
From a security perspective, you just gave every user a dial that controls their own billing status.
From API endpoint to curl
You don't need the browser console. The vulnerability is in the API. If your app has a PATCH endpoint that updates user profile fields — which almost every Supabase app does — an attacker can call it directly:
curl -X PATCH "https://yourapp.supabase.co/rest/v1/profiles?id=eq.YOUR_USER_ID" \
-H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI..." \
-H "Authorization: Bearer USER_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"is_paid": true, "subscription_tier": "pro"}'
Same attack, no UI needed. Or from the browser console with the Supabase client already loaded, which is even faster. The point: there's no friction between user and premium tier. The client is already authenticated, the endpoint is already exposed, the RLS policy is already permissive.
This is by design on Supabase's side — the anon key is meant to be public. The security model depends entirely on RLS policies filtering what that key can do. When the UPDATE policy is just USING (auth.uid() = id) without column restrictions, the anon key becomes a write-anything key for your own row.
It's not just is_paid
The subscription bypass gets attention, but the underlying issue is mass assignment — accepting arbitrary field updates without checking which fields the user should actually control. In Supabase apps, any column in the user's row is a target:
// Subscription bypass
await supabase.from('profiles').update({ is_paid: true });
// Role escalation
await supabase.from('profiles').update({ role: 'admin' });
// Credit manipulation
await supabase.from('profiles').update({ credits: 99999 });
// Trial extension
await supabase.from('profiles').update({ trial_ends_at: '2099-12-31' });
// Feature flag override
await supabase.from('profiles').update({ features: { analytics: true, export: true } });
Each of these is a single line. Each one works if the UPDATE policy doesn't restrict columns. The attacker doesn't need to find an API key or reverse-engineer a backend. They just need to guess a column name — and column names are often visible in network requests or the JavaScript bundle.
This maps to OWASP A01: Broken Access Control and the mass assignment pattern in A04: Insecure Design. It's one of the oldest vulnerability classes in web development. AI code generators reproduce it consistently because they don't model which fields are sensitive.
The fix: three layers deep
One layer can have a bug. Three layers failing simultaneously is unlikely. You want all three.
Layer 1: Column-aware RLS policies
The most direct fix. Your UPDATE policy should verify that sensitive columns haven't changed:
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (
is_paid = (SELECT is_paid FROM profiles WHERE id = auth.uid())
AND subscription_tier = (SELECT subscription_tier FROM profiles WHERE id = auth.uid())
AND role = (SELECT role FROM profiles WHERE id = auth.uid())
AND credits = (SELECT credits FROM profiles WHERE id = auth.uid())
);
This says: you can update your own row, but if is_paid, subscription_tier, role, or credits differ from the current values, the write is rejected. Users can still change their name, avatar, and bio. They just can't touch the fields that control access and billing.
The subqueries run inside the same transaction, so there's no race condition. PostgreSQL evaluates WITH CHECK against the proposed new row.
Layer 2: Subscription changes go through the server
The client should never be the source of truth for payment status. Subscription fields should only change via server-side code triggered by your payment provider:
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const event = await verifyStripeWebhook(request);
if (event.type === 'checkout.session.completed') {
const userId = event.data.object.metadata.user_id;
// Service role key — server-side only, bypasses RLS
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
await supabase
.from('profiles')
.update({ is_paid: true, subscription_tier: 'pro' })
.eq('id', userId);
}
return new Response('ok');
}
The service role key bypasses RLS because it's meant for trusted server operations. It never appears in client-side code. This webhook is the only path through which is_paid should ever change.
Layer 3: Server-side access checks
Even with the database locked down, don't trust the client to decide what to render. Check subscription status on the server before serving premium content:
// Server component or API route
async function getPremiumContent(userId: string) {
const { data: profile } = await supabaseAdmin
.from('profiles')
.select('is_paid, subscription_tier')
.eq('id', userId)
.single();
if (!profile?.is_paid) {
return { error: 'Upgrade required' };
}
return { data: premiumData };
}
If your only gate is {user.is_paid && <PremiumDashboard />} in a client component, someone can patch the JavaScript to always evaluate true. Server-side checks are the real gate. Client-side checks are cosmetic.
Test your own app in two minutes
You don't have to take this on faith. Open your Supabase SQL Editor and check.
Find your sensitive columns:
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'profiles'
AND table_schema = 'public';
Look for is_paid, is_subscribed, subscription_tier, plan, role, credits, trial_ends_at. If any of these exist, they're targets.
Check if RLS is enabled:
SELECT tablename, rowsecurity
FROM pg_tables
WHERE tablename = 'profiles' AND schemaname = 'public';
If rowsecurity is false, stop reading and fix that first. Every column is writable by any authenticated user.
Check your UPDATE policy:
SELECT policyname, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'profiles' AND cmd = 'UPDATE';
If with_check is null or doesn't reference your sensitive columns, the vulnerability is live.
Confirm by running the exploit:
const { data, error } = await supabase
.from('profiles')
.update({ is_paid: true })
.eq('id', (await supabase.auth.getUser()).data.user.id)
.select();
console.log(data, error);
If error is null and data shows is_paid: true — there it is.
What actually happened here
You built the premium feature correctly: Stripe handles checkout, a webhook updates the database when payment completes, and your server trusts the webhook because it's cryptographically verified.
But then you also built a generic profile update endpoint because builders need to let users change their name and avatar. That endpoint went through Supabase directly via RLS, which is common and reasonable. You didn't explicitly forbid updates to is_paid because, well, why would a user try to change it? It's not in the UI. The RLS policy just says "you can edit your own row," and the database has no reason to be suspicious.
That's not sloppiness. That's the default. Lovable and Cursor generate this exact pattern because it's the fastest path from "I want a subscription feature" to "it works."
But "it works" and "it's secure" are different things. You now have two paths to change subscription status. One is locked down (Stripe webhook). One isn't (the profile endpoint). An attacker only needs to find the weaker path.
What you should do right now
- Check your RLS policies right now. Open your Supabase SQL Editor and run these two queries:
SELECT tablename, rowsecurity FROM pg_tables WHERE tablename = 'profiles' AND schemaname = 'public';
SELECT policyname, cmd, qual, with_check FROM pg_policies WHERE tablename = 'profiles' AND cmd = 'UPDATE';
If rowsecurity is false, you have no RLS at all — stop and fix that first. If with_check is null or doesn't reference is_paid, you have this vulnerability.
- Add column-aware RLS policies. This is the one-line fix that locks down subscription fields:
CREATE POLICY "Users can update own profile (safe columns only)"
ON profiles FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (
is_paid = (SELECT is_paid FROM profiles WHERE id = auth.uid())
AND subscription_tier = (SELECT subscription_tier FROM profiles WHERE id = auth.uid())
AND role = (SELECT role FROM profiles WHERE id = auth.uid())
AND credits = (SELECT credits FROM profiles WHERE id = auth.uid())
);
This says: you can update your row, but these four columns must not change from their current values. Users can still edit their name and avatar. They can't touch the fields that control access and billing.
-
Don't rely on the client to decide what data to return. Server-side checks are the real gate. When a user requests premium content, verify their subscription status on the server (in an API route or server component) before returning anything. Client-side checks are cosmetic.
-
Understand the full chain. Stripe webhook → service role update. Profile update endpoint → anon key + RLS. These are intentionally different. The webhook is trusted because it's cryptographically verified. The profile endpoint is trusted because RLS locked it down. If either path is open, the entire feature is bypassed.
-
Scan before you ship. Flowpatrol checks for exactly this: writable subscription fields, missing column-level RLS restrictions, and APIs that expose sensitive data. Paste your URL and get a report in under five minutes.
You built something people want to pay for. Lock down both paths.
For a deeper dive on RLS, see our Supabase RLS guide. If you're building on Lovable specifically, check out How to Secure Your Lovable App.