Stripe Webhooks + Supabase RLS Leak (2026 Fix)

Akshit Ahuja
Co-Founder & Lead Engineer
If you run a multi-tenant SaaS on Supabase and take payments with Stripe, you are sitting on a nasty little foot-gun.
It usually shows up like this:
A customer upgrades. The webhook hits. Your app marks the wrong workspace as paid.
Or worse: you provision features in Tenant A based on Tenant B's event. Congrats, you just built cross-tenant data access. RLS did not save you.
This post is about that failure mode in 2026, why it happens, and how we fix it when we rescue apps.
The short version: the service_role key bypasses RLS, Stripe webhooks are not tenant-aware by default, and people glue things together with "whatever ID is in the event". That combo leaks data.
The setup that causes the leak (common in 2026)
Most Next.js + Supabase + Stripe stacks look like this:
- Next.js App Router route: /api/stripe/webhook
- Verify signature with Stripe's signing secret
- Parse event
- Use Supabase server client with SERVICE_ROLE_KEY
- Update subscription row, update plan, unlock features
It feels right because the webhook is server-to-server, so you think "admin key is fine".
The problem is not Stripe. The problem is how you map a Stripe object to a tenant.
In a single-user app, you can map subscription to user_id and be done.
In a multi-tenant app, you need an org_id (workspace_id, account_id, tenant_id, pick your poison) on every billing write.
Most teams skip that and do a lookup like:
- Find user by stripe_customer_id
- Get their current org from a profile row
- Update org subscription
That "current org" step is where the leak lives.
Why RLS does not protect you here
Supabase RLS only applies when queries run under roles that are subject to policies.
The service_role key is not subject to your RLS policies. It is meant for admin operations and it bypasses RLS on purpose.
So if your webhook handler runs a query like:
"update subscriptions set status='active' where org_id = X"
and X is wrong, Postgres will happily update the wrong rows. No policy checks. No safety net.
If you were relying on RLS as your last line of defense, it is gone.
The specific bug pattern we see in rescues
Here is the most common version:
1) You store stripe_customer_id on the user profile.
2) In the webhook, you load the user by stripe_customer_id.
3) You load the user's "active" org from a join table (memberships) using something like "latest" or "default".
4) You write subscription status to that org.
That fails when:
- The user belongs to multiple orgs.
- The user switches orgs in the UI after checkout but before the webhook lands.
- An admin upgrades on behalf of someone else.
- You have invite flows where membership rows change around the same time.
Now sprinkle in retries. Stripe retries webhooks. You also retry. So the wrong write can happen multiple times.
How to reproduce the leak in 5 minutes
You can reproduce this locally in a sandbox project:
- Create two orgs: org_a and org_b.
- Add the same user to both.
- In your app, set the user's "current_org_id" to org_a.
- Start checkout for org_a.
- Before the Stripe webhook arrives, switch current_org_id to org_b.
- Webhook handler looks up user, uses current_org_id, updates org_b.
That is it. No fancy exploit. Just normal usage.
The fix: make billing tenant-aware at the Stripe object level
The fix is boring, which is why it works.
You need a stable tenant identifier that flows through the Stripe objects and comes back in the webhook.
Two practical patterns:
Pattern A: Put tenant_id in Stripe metadata
When you create the Checkout Session, set metadata on the session and the subscription:
- metadata.tenant_id = your org_id
- metadata.environment = prod or staging (yes, people mix these)
Then, in the webhook, you read metadata. You do not guess the tenant.
If metadata is missing, fail the webhook and alert. Do not fall back to "current org".
Pattern B: Store tenant_id on the Stripe Customer
If your pricing is per-tenant and you want one Stripe Customer per tenant, this is even cleaner.
- Create Stripe Customer per org.
- Store stripe_customer_id on the org row.
Then your webhook mapping is:
subscription.customer -> org
No user lookup. No membership joins. Less surface area.
I like this pattern for B2B SaaS.
What we actually implement (a safe webhook pipeline)
This is the pipeline we ship in 2026 when we take over a messy billing system.
1) Verify signature and parse the event
Obvious, but do it right. Reject requests without a valid signature.
Also log:
- event.id
- event.type
- created timestamp
- the Stripe object id (subscription id, invoice id, checkout session id)
You need those when Stripe support asks questions.
2) Idempotency: store event.id before doing anything
Stripe will retry. Your function might time out. Your deploy might roll.
Create a table like billing_events:
- id (stripe event id, primary key)
- type
- received_at
- processed_at
- org_id
- status (ok, failed)
- error text
Insert it first. If it already exists, exit 200.
This alone saves teams from duplicate provisioning and duplicate emails.
3) Resolve tenant from Stripe, not from your app state
Pick one:
- metadata.tenant_id
- org.stripe_customer_id
- subscription.metadata.tenant_id
Then resolve org row with a single query.
If you cannot resolve, mark billing_events as failed and page someone. Do not guess.
4) Use service_role, but treat it like a chainsaw
I am not anti service_role. I am anti "use it everywhere".
In a webhook, service_role is fine if you follow two rules:
- Always include org_id in every write.
- Add database constraints that make wrong writes hard.
For example:
- subscriptions table has a unique (org_id) if you only allow one subscription per org.
- invoices table has foreign key to org.
- membership changes do not affect billing mapping.
And keep the webhook code tiny.
5) Put tenant_id on the rows you write, every time
Your billing tables should be tenant-scoped. Always.
If you write to a table without org_id, you will regret it.
6) Do not call user-facing business logic from the webhook
Webhooks should be data writes plus a small queue trigger.
Do not:
- create projects
- send emails
- provision seats
Do those in a job that can be retried safely, with org_id as input.
A concrete schema that avoids drama
A minimal set of tables:
- orgs (id, name, stripe_customer_id)
- org_subscriptions (org_id, stripe_subscription_id, status, current_period_end, plan)
- billing_events (stripe_event_id, org_id, type, status, error)
Constraints that save you:
- orgs.stripe_customer_id is unique
- org_subscriptions.org_id is primary key
- org_subscriptions.stripe_subscription_id is unique
Now, even if a bug tries to attach one subscription to two orgs, Postgres blocks it.
Costs and timelines (real numbers)
When this bug hits production, it is never just "one line".
Here is what it usually costs to fix for US-based teams.
- 4-8 hours: audit webhook handler and Stripe object mapping
- 4-12 hours: add missing columns, constraints, and backfill org_id in billing tables
- 4-10 hours: build billing_events idempotency and replay tooling
- 2-6 hours: write tests for multi-tenant edge cases
- 2-6 hours: ship and monitor
Ballpark: 12 to 34 engineering hours.
At $150 to $250 per hour (common for senior contractors in the US/UK), that is roughly $1,800 to $8,500.
If the leak caused refunds, add:
- 1-3 hours of support time per affected customer
- charge dispute fees
- a trust hit you cannot put on a spreadsheet
Tests that catch this before users do
If you only test billing with a single user in a single org, you are basically not testing.
Write these integration tests:
1) Same user in two orgs, upgrade org_a, ensure org_b stays unchanged.
2) Switch "current org" between checkout and webhook, ensure mapping still correct.
3) Replay the same Stripe event twice, ensure you do one write.
4) Send events out of order (invoice.paid before customer.subscription.updated), ensure state ends correct.
This is not theory. Stripe events arrive out of order in real life.
When you should not use service_role at all
If your team is new to Supabase, the safest move is to avoid service_role in the webhook and call a Postgres function instead.
Create a security definer function that:
- takes org_id, stripe_subscription_id, status
- validates org exists
- upserts org_subscriptions
- records billing_events
Then your webhook handler only calls that function.
It is harder to mess up because the SQL function becomes the single choke point.
Quick checklist (print this)
- One Stripe Customer per tenant, or tenant_id in Stripe metadata
- No tenant inference from "current org"
- billing_events table with Stripe event.id as primary key
- Unique constraints on stripe_customer_id and stripe_subscription_id
- All billing rows have org_id
- Integration tests for multi-org users
If you do those, you can stop sweating every Stripe retry.
Want help fixing it fast?
If you are a founder in the US/UK/EU and you suspect billing is writing into the wrong workspace, do not wait.
The first step is a 30-minute audit of your webhook mapping and schema constraints. If it is wrong, we can usually patch it in a day and backfill safely.
The embarrassing part is not that this bug exists. The embarrassing part is shipping it twice.
---
Related reading

Akshit Ahuja
Co-Founder & Lead Engineer
Backend systems specialist who thrives on building reliable, scalable infrastructure. Akshit handles everything from API design to third-party integrations, ensuring every product HeyDev ships is production-ready.