In early 2026, a security researcher auditing three live vibe-coded SaaS products found that all of them could be "upgraded" to paid status without paying a single cent. The method was embarrassingly straightforward: open the browser console, call supabase.from('profiles').update({'is_subscribed': true}).eq('id', userId), and refresh the page. Paid features unlocked immediately. No payment required. The combined post about this vulnerability pattern drew over 1,500 upvotes across developer communities — not because the attack was sophisticated, but because it was so easy and so common.
The root cause is not a bug in Stripe, and it is not a bug in Supabase. It is an architectural pattern that AI coding tools reliably produce: the payment confirmation happens on the frontend, the database update follows from client-side code, and there is no server-side verification that a real payment was processed before the subscription flag is flipped. This article covers exactly how to prevent it — with the specific Stripe webhook handler, the right use of the Supabase service role key, and the RLS policy that blocks direct is_subscribed writes as a second line of defence. This article is part of our broader Supabase Security Checklist for vibe coders — if you haven't read that first, it covers the full landscape of RLS vulnerabilities your app may already have.
Why Frontend-Only Payment Verification Is Broken by Design
Quick Answer: Your frontend receives a "success" signal from Stripe Checkout when a user lands on the success URL. But that URL can be visited directly, without completing payment. Any code that writes to your database based on that signal — without first verifying the payment server-side — can be triggered by anyone for free.
What AI tools generate and why it's insufficient
When you ask Lovable, Cursor, or Bolt to "add Stripe payments," the generated code typically follows this pattern: create a Stripe Checkout session server-side (correct), redirect the user to Stripe's hosted payment page (correct), and then — on the success URL page — run a client-side function that calls supabase.from('profiles').update({'is_subscribed': true}) (dangerously wrong). The Stripe Checkout success URL tells you that the user clicked through a Stripe-hosted payment page. It does not tell you that they paid. The URL is constructed when you create the session and is entirely predictable — it's usually something like https://yourapp.com/success?session_id=cs_test_abc123. A user can visit that URL directly, without going through Stripe at all.
The broader problem is that anything running in the user's browser is under the user's control. The JavaScript bundle you ship is readable. The API calls it makes can be replicated in DevTools or a curl command. A developer at a startup called Enrichlead described this exact experience in late 2025: within 72 hours of launch, users found that changing a single value in the browser console granted them free access to all paid features. The code had been generated entirely by an AI tool, the Stripe integration looked complete, and the vulnerability was invisible until someone with basic developer skills looked for it. This is not an isolated incident — according to security research published by VibeEval in February 2026, payment bypass was among the top three vulnerabilities found in a review of vibe-coded apps accepting real payments.
The three ways users exploit this pattern
There are three common attack surfaces, all enabled by the same architectural flaw:
| Attack Method | What the User Does | Why It Works |
|---|---|---|
| Direct success URL visit | Navigates to /success?session_id=anything |
Frontend code runs on any visit, not just post-payment visits |
| Direct API update | Calls Supabase JS client directly: .update({'is_subscribed': true}) |
RLS UPDATE policy permits users to modify their own row without column restrictions |
| Credit balance manipulation | Sets credits = 99999 via client update |
Credits column is not protected by RLS, allowing arbitrary values |
"Users could upgrade themselves to paid tiers by modifying frontend state or Supabase rows — no server-side payment verification." — VibeEval Lovable Security Report, February 2026
- Open DevTools on your own app, find your user ID from the auth session, and try calling
supabase.from('profiles').update({'is_subscribed': true}).eq('id', yourUserId). If it succeeds, you have this vulnerability. - Check whether your success URL page contains any database write logic — if it does, move it to a webhook handler.
Setting Up Stripe Webhook Signature Verification in Supabase Edge Functions
Quick Answer: A Stripe webhook handler receives a signed HTTP POST from Stripe's servers when a real payment event occurs. You verify the signature using your STRIPE_WEBHOOK_SECRET before doing anything with the payload. Only after that verification passes do you update the database — using the service role key to bypass RLS. This is the only flow that can't be faked by a user.
Why webhook signature verification matters
Stripe signs every webhook payload using your account's webhook signing secret. When your server receives a POST to its webhook URL, it reconstructs the expected signature from the raw request body and that secret, then compares it to the stripe-signature header in the incoming request. If they match, the request genuinely came from Stripe. If they don't, someone is trying to fake a payment event to your server. Without this verification step, an attacker could POST a fake checkout.session.completed event directly to your webhook URL and trigger the same subscription upgrade that a real payment would cause.
Step-by-step: Supabase Edge Function webhook handler
Create a new Edge Function — supabase functions new stripe-webhook — and disable JWT authentication for it (Stripe's servers don't have a Supabase user session). Set this in your supabase/config.toml:
[functions.stripe-webhook]
verify_jwt = false
Then write the handler itself. The critical steps — in order — are: read the raw body, extract the signature header, verify, then act:
// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@14'
import { createClient } from 'npm:@supabase/supabase-js@2'
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!
Deno.serve(async (req) => {
// 1. Read the raw body — must be the unparsed bytes for signature check
const body = await req.text()
const signature = req.headers.get('stripe-signature')
// 2. Verify the signature — NEVER skip this step
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature!, webhookSecret)
} catch (err) {
console.error('Webhook signature verification failed:', err.message)
return new Response('Webhook signature verification failed', { status: 400 })
}
// 3. Handle the event — only act on events we care about
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
// 4. Initialize Supabase with service role key — bypasses RLS
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! // NOT the anon key
)
// 5. Update the user — using metadata set when creating the session
const userId = session.metadata?.supabase_user_id
if (userId) {
const { error } = await supabase
.from('profiles')
.update({
is_subscribed: true,
plan_tier: 'pro',
stripe_customer_id: session.customer as string,
})
.eq('id', userId)
if (error) {
console.error('Database update failed:', error)
return new Response('Database update failed', { status: 500 })
}
}
}
return new Response(JSON.stringify({ received: true }), { status: 200 })
})
Notice the critical detail in step 5: the userId comes from session.metadata.supabase_user_id. You need to embed this when creating the Checkout session on your server:
// When creating the Checkout session (server-side only)
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: 'price_your_pro_plan_id', quantity: 1 }],
success_url: `${origin}/dashboard?upgraded=true`,
cancel_url: `${origin}/pricing`,
metadata: {
supabase_user_id: userId // embed the authenticated user's ID
}
})
Add the required secrets to your Edge Function environment using the Supabase CLI:
supabase secrets set STRIPE_SECRET_KEY=sk_live_...
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...
supabase secrets set SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIs...
supabase secrets set SUPABASE_URL=https://yourproject.supabase.co
Finally, register the deployed function URL in your Stripe Dashboard under Developers → Webhooks. Select the checkout.session.completed event at minimum, and add customer.subscription.deleted to handle cancellations.
Stripe signature verification requires the raw, unparsed request body — not a JSON-parsed object. If you call req.json() before stripe.webhooks.constructEvent(), the body has been re-serialized and the signature check will always fail. Always use req.text() or the equivalent raw body reader. This is the single most common reason webhook verification fails in freshly written handlers.
- Create a
stripe-webhookEdge Function with the code above and deploy it. - Register the function URL in Stripe Dashboard and note the signing secret — add it to your Edge Function secrets immediately.
- Remove all subscription-status database writes from your success URL page.
The Difference Between Anon Key and Service Role Key — When to Use Each
Quick Answer: The anon key is your public key — safe to ship in frontend code, constrained by RLS policies. The service role key is your admin key — bypasses all RLS, must never appear in the browser. Exposing the service role key is equivalent to giving every user unrestricted database access.
What each key actually does
Every Supabase project has two keys. The anon key (also called the public key) is used in client-side code. When a request arrives at Supabase with the anon key, Row Level Security policies are fully enforced — users can only see and modify data their RLS policies permit. The anon key being visible in your frontend JavaScript bundle is intentional and expected; Supabase's security model is built around the assumption that this key is public. The anon key is not a secret.
The service role key is categorically different. A request authenticated with the service role key bypasses all RLS policies entirely. Every table, every row, every column — readable and writable without restriction. Supabase's own documentation states this explicitly: the service role key "has the ability to bypass Row Level Security." This is exactly what the webhook handler needs — it needs to update a user's is_subscribed column without being blocked by the RLS policy that prevents users from updating that column themselves. But if this key leaks into your frontend code, any user can instantiate a Supabase client with it and do anything they want to your database.
Where each key belongs in a Next.js app
| Location | Anon Key | Service Role Key |
|---|---|---|
| React components / client code | ✅ Safe — use NEXT_PUBLIC_SUPABASE_ANON_KEY |
❌ Never — the key would be visible in the JS bundle |
Next.js API routes (/api/) |
✅ For user-context operations | ✅ Only for admin operations (webhooks, migrations) |
| Supabase Edge Functions | Rarely needed | ✅ Standard for webhook handlers |
.env.local (Next.js) |
✅ Can use NEXT_PUBLIC_ prefix |
✅ Must NOT use NEXT_PUBLIC_ prefix |
The quickest way to check whether your codebase has this wrong: search your repository for NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY or any NEXT_PUBLIC_ variable containing "service" or "admin." If you find one, the service role key is in your client bundle. Rotate the key immediately in your Supabase project settings. The old key is permanently compromised — not "possibly compromised," permanently. Any user who saw your frontend JS has it.
- Search your repo for
NEXT_PUBLIC_SUPABASE_SERVICE— rotate the key immediately if found. - Verify that your webhook handler uses
SUPABASE_SERVICE_ROLE_KEY(no NEXT_PUBLIC_ prefix). - Check that no client component imports a Supabase client initialized with the service role key.
Writing RLS Policies That Block Direct is_subscribed and Credit Balance Modifications
Quick Answer: Your webhook handler is the primary defense. RLS is the backup. Write UPDATE policies with a WITH CHECK clause that prevents users from changing subscription-related columns — even if they call the Supabase API directly from the browser with a valid session token.
The WITH CHECK clause: what it is and why it matters
Most RLS guides show the USING clause — the condition that determines whether a user can access a row at all. But UPDATE policies also support a WITH CHECK clause, which inspects the new values being written. This is where you enforce column-level protection without needing to split your table into multiple tables or use Postgres column-level privileges. The pattern for locking subscription columns looks like this:
-- Allow users to update their own profile row
-- but prevent changes to subscription-related columns
CREATE POLICY "users_update_own_profile"
ON "public"."profiles"
FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (
auth.uid() = id
-- These columns must remain unchanged (equal to their current value)
AND is_subscribed = (SELECT is_subscribed FROM profiles WHERE id = auth.uid())
AND plan_tier = (SELECT plan_tier FROM profiles WHERE id = auth.uid())
AND stripe_customer_id = (SELECT stripe_customer_id FROM profiles WHERE id = auth.uid())
AND credits = (SELECT credits FROM profiles WHERE id = auth.uid())
);
This policy allows a user to update any column except the four locked ones. Attempts to change is_subscribed, plan_tier, stripe_customer_id, or credits will be silently rejected by PostgreSQL with a row-level security violation — no error is shown to the user, the update simply fails to apply. The webhook handler, using the service role key, bypasses this policy entirely and can still update these columns after verifying a real payment.
Protecting credit balances with a Postgres function
The credit balance case deserves special treatment, because credits can be decremented (when a user consumes them) as well as incremented (when a user pays). You do not want to lock the credits column entirely — you need to allow deductions from client-side feature usage while preventing users from setting an arbitrary balance. The cleanest solution is a SECURITY DEFINER function that runs with elevated privileges and handles the decrement atomically:
-- Function that decrements credits atomically (cannot be used to add credits)
CREATE OR REPLACE FUNCTION consume_credits(amount INTEGER)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER -- runs with the function owner's privileges, not the caller's
AS $$
BEGIN
UPDATE profiles
SET credits = GREATEST(0, credits - amount) -- never goes negative
WHERE id = auth.uid()
AND credits >= amount; -- only succeeds if user has enough credits
IF NOT FOUND THEN
RAISE EXCEPTION 'Insufficient credits';
END IF;
END;
$$;
Call this function from the client instead of calling .update({'credits': newBalance}) directly. A user can decrement their own credits (legitimately using the product) but cannot set credits = 99999 because the function only subtracts. Credit additions still go exclusively through the webhook handler. For a comprehensive look at how these policies interact with the rest of your Supabase security posture, the Supabase security checklist covers audit patterns for detecting gaps in existing RLS configurations.
- Update your profiles UPDATE policy to include the WITH CHECK clause locking your subscription columns.
- Replace any client-side
.update({'credits': newValue})calls with aconsume_credits()SECURITY DEFINER function. - Test the policy: try updating
is_subscribedfrom the browser — it should silently fail.
The Correct End-to-End Flow: Stripe → Edge Function → RLS-Protected Database Update
Quick Answer: The secure payment flow has five steps and zero database writes on the client side. The user pays on Stripe's servers. Stripe notifies your server. Your server verifies the notification. Your server updates the database with elevated permissions. Your client reads the updated state on the next request.
The five-step secure payment flow
The architecture looks like this end to end. Each step is on a different system, and the chain cannot be shortcut:
┌─────────────────────────────────────────────────────────────────────┐
│ Step 1: User clicks "Upgrade" in your app (browser) │
│ → Your server creates a Stripe Checkout session │
│ → Embeds supabase_user_id in session.metadata │
│ → Redirects user to Stripe's hosted payment page │
├─────────────────────────────────────────────────────────────────────┤
│ Step 2: User completes payment on Stripe's servers │
│ → User is redirected to your /success page │
│ → SUCCESS PAGE DOES NOTHING to the database │
├─────────────────────────────────────────────────────────────────────┤
│ Step 3: Stripe POSTs checkout.session.completed to your webhook URL │
│ → Edge Function receives the signed payload │
│ → stripe.webhooks.constructEvent() verifies signature │
│ → If signature fails: return 400, stop here │
├─────────────────────────────────────────────────────────────────────┤
│ Step 4: Edge Function updates the database │
│ → Initializes Supabase with SUPABASE_SERVICE_ROLE_KEY │
│ → Updates is_subscribed, plan_tier, stripe_customer_id │
│ → RLS is bypassed (service role key) — update succeeds │
├─────────────────────────────────────────────────────────────────────┤
│ Step 5: User's next request reads updated state │
│ → Don't rely on JWT claims for subscription status │
│ → Re-read from database on every request that checks access │
│ → User sees their new paid features │
└─────────────────────────────────────────────────────────────────────┘
One often-missed detail: don't trust JWT claims for subscription gating
Supabase JWTs (the tokens that authenticate users in your app) are valid for a configurable duration, typically one hour. If you embed is_subscribed as a custom claim in your JWT, a user who cancels their subscription will remain "subscribed" until their token expires and is refreshed. More critically in the other direction, if a user somehow tampers with a JWT claim (which is harder to do with Supabase's signing but not impossible for all implementations), they could fake subscription status without touching the database. The safest approach: never put subscription status in JWT claims. Always read the current state from the database at the point where you gate access to paid features. The extra database read is negligible compared to the security and correctness guarantees it provides.
This pattern — where an AI tool generates something that looks right and even tests correctly in development, but has a critical architectural flaw that only becomes exploitable in production — is exactly the kind of problem covered in our broader guide to why vibe-coded apps break in production. The Stripe integration works locally. The subscription update triggers. The feature unlocks. It is only in production, where real users probe the boundaries, that the missing server-side verification becomes apparent.
- Trace your current payment flow end to end — mark every step that writes to the database and confirm it is server-side.
- Search your success URL page component for any
supabase.from()orfetch('/api/')calls — move them to the webhook handler. - Verify that access to paid features reads subscription status from the database, not from a JWT claim or client-side state.
Frequently Asked Questions
Why can users upgrade themselves to paid in my vibe-coded app?
Because AI-generated Stripe integrations typically handle payment confirmation on the frontend — the client code receives a "success" signal from Stripe Checkout and immediately updates the database directly. There is no server-side check confirming that a real payment was processed. Any user can skip the payment step and trigger that same database update manually using the Supabase JavaScript client or a direct API call, as long as your RLS policies permit users to write to columns like is_subscribed or is_paid.
How do I verify Stripe payments server-side in a Supabase app?
You need a Stripe webhook handler running in a server-side environment — either a Supabase Edge Function or a Next.js API route. Stripe sends a signed POST request to your endpoint when a payment event occurs. Your handler verifies the signature using stripe.webhooks.constructEvent() with your STRIPE_WEBHOOK_SECRET, then uses the Supabase service role key to update the database. Because the service role key bypasses RLS, the update goes through — and because the update only happens after signature verification, it can only be triggered by a real Stripe event.
What is the Supabase service role key and when should I use it?
The service role key is a Supabase API key that bypasses all Row Level Security policies — it has full read and write access to every table. Use it exclusively in server-side code you control: Supabase Edge Functions, Next.js API routes, and other backend environments. Never include it in frontend code, client-side JavaScript bundles, or any environment variable prefixed with NEXT_PUBLIC_.
How do I set up Stripe webhooks with Supabase Edge Functions?
Create an Edge Function with verify_jwt = false in config.toml so Stripe can call it without a user session. Inside the function, use req.text() to read the raw body, extract the stripe-signature header, and call stripe.webhooks.constructEvent() with your STRIPE_WEBHOOK_SECRET to verify the payload. On a checkout.session.completed event, initialize Supabase with SUPABASE_SERVICE_ROLE_KEY and update the user's subscription columns. Register the deployed function URL in Stripe's webhook dashboard.
Can I use RLS to prevent users from modifying their own subscription status?
Yes — use a WITH CHECK clause on your UPDATE policy: WITH CHECK (auth.uid() = id AND is_subscribed = (SELECT is_subscribed FROM profiles WHERE id = auth.uid())). This locks the column's value to what it already is in the database. Only the webhook handler, using the service role key that bypasses RLS, can change it. This is your second line of defense after webhook verification.
Why does my AI-generated Stripe integration not include webhook verification?
AI coding tools generate Stripe integrations that reach the happy path quickly — the checkout flow. Webhook handling requires a server-side endpoint, environment secrets, and database writes with elevated permissions. This is a more complex multi-system flow that tools like Lovable, Cursor, and Bolt frequently oversimplify by using the Stripe Checkout success URL redirect as the payment confirmation signal instead. The result works in demos but is trivially bypassed in production.
How do I prevent credit balance manipulation in a Supabase-based SaaS?
Never allow client-side code to directly write to a credits column. Route all credit additions through the webhook handler. For credit deductions, use a PostgreSQL SECURITY DEFINER function that decrements the balance atomically — this prevents clients from setting an arbitrary absolute value. Your RLS UPDATE policy should restrict users from modifying the credits column directly; they can only call the deduction function.
What is the correct flow for Stripe → Edge Function → RLS-protected database update?
Five steps: (1) User pays on Stripe — no database write. (2) User is redirected to your success page — still no database write. (3) Stripe POSTs a signed webhook event to your Edge Function. (4) The function verifies the signature, then uses the service role key to update subscription columns. (5) The user's next page load reads the updated subscription status from the database. Never gate paid features on JWT claims — always re-read from the database.
aicourses.com Verdict
The subscription bypass vulnerability is one of the most avoidable security failures in vibe-coded SaaS products — and one of the most common. The AI tools aren't malicious; they generate working payment flows that ship fast and demo well. The structural flaw is invisible until someone looks for it. The fix is three concrete things: a Stripe webhook handler with signature verification, the service role key used correctly on the server, and a WITH CHECK RLS policy that locks subscription columns. None of these require deep security expertise. They require knowing the pattern and doing it before launch rather than after a breach report forces the issue. Stripe webhook and Supabase service role key behavior verified as of March 2026.
The practical recommendation: treat "add Stripe payments" as a two-part task. Part one (which your AI tool handles fine) is the checkout flow. Part two (which you must add manually) is the webhook handler and the RLS policy. Before shipping any monetization feature, open DevTools and try to trigger the subscription update without paying. If you can do it, so can your users. The test takes 30 seconds and will tell you definitively whether your fix worked. For the broader picture of what else your AI-built Supabase app might be leaking, the Supabase Security Checklist covers RLS misconfigurations, exposed service role keys, and the five patterns AI tools routinely get wrong. And if you're thinking about whether your AI tool's generated code has other production-readiness gaps beyond security, our article on vibe-coded apps that break in production covers the full class of issues. Pricing verified as of March 2026; Stripe webhook event types may expand — check Stripe's documentation for the current list.
The next article in this cluster goes deeper on testing: once you have RLS policies in place, how do you verify they actually work the way you think they do? The answer involves running SQL queries as specific authenticated users and checking your policies against adversarial scenarios — the same approach a real security auditor would use.
Want to learn more about AI? Download our aicourses.com app through this link and claim your free trial!
