Carolina, a 35-year-old growth marketer in São Paulo, had been running the thMenu affiliate program for four months. In Q1 she earned $4,200 in commissions and was about to file her first Wise payout request. On the dashboard's payout-status panel she noticed a small UI bug — the postback delivery log flashed a "delivery failed" badge even when the log was empty.
Her developer instinct kicked in. She opened DevTools, watched the Network tab, and copied the failing Supabase REST call into the Console to inspect the response. One paste: await window.__sb.from('affiliate_postback_log').select('affiliate_id, url, response_excerpt').limit(50).
The response carried rows that didn't belong to her. Forty-seven other affiliates' postback URLs, 200-character response excerpts, commission IDs, delivery timestamps. One row was a major lifestyle YouTube influencer's S2S endpoint — and the URL embedded an analytics-platform API key in the query string (against the operator's own ToS). Another row leaked customer PII via response reflection from a regional restaurant chain's internal CRM hook.
Three Tables, Zero Policies
Carolina screenshotted the response and emailed security@synaltix.io privately. The thMenu security team opened the forensic in fifteen minutes. The vulnerability traced to a single Supabase migration — supabase/migrations/20260513000005_affiliate_phase3.sql — which created three new tables for the Phase-3 affiliate launch:
affiliate_postback_log— every S2S postback delivery attempt + response excerptaffiliate_tier_events— automatic commission-rate adjustments per affiliate over timeaffiliate_1099_alerts— IRS 1099-NEC threshold notifications (YTD paid per affiliate per calendar year)
All three tables shipped without an ENABLE ROW LEVEL SECURITY statement. By Supabase default, that means anon-key callers — every browser session via the public NEXT_PUBLIC_SUPABASE_ANON_KEY — can SELECT * across all rows on the platform.
The leak surfaces were not abstract. Each row carried direct competitive intelligence:
- Postback URL: tells you which analytics platform your competitor integrates with (Mixpanel, Segment, Heap, custom — each picks a different traffic strategy)
- Response excerpt: a 200-character slice of the operator's endpoint response — often includes session IDs, affiliate name reflections, and occasional PII bleed-through
- Tier events: full history of automatic commission-rate changes. If you know your competitor was bumped from 25% to 30% three months ago at 50 active customers, you know their current customer count and effective economics
- 1099 alerts: annual revenue per affiliate, by calendar year, with the IRS threshold ($600 USD/year for US affiliates) as the anchor
Why Default-Allow Anon Reads Are Wrong For Multi-Tenant SaaS
Supabase's documentation is explicit: RLS is not enabled by default on new tables. The default posture trusts the developer to opt in. For single-tenant prototypes and internal tooling, that's fine. For multi-tenant production SaaS, it's a footgun.
The footgun has a specific shape. The anon key is by design shipped to every browser via the public Next.js client SDK initialization. Any visitor of affiliate.thmenu.com can read it from window.__sb, from `__NEXT_DATA__`, from a sourcemap, or just from the Network tab. Once the anon key is in the visitor's hand, every table without an explicit RLS policy is an open door.
The mitigation isn't subtle — every new table needs ENABLE ROW LEVEL SECURITY + at least one policy at create time. The Phase-3 migration missed three tables in a row, which suggests the omission wasn't a one-off — it was a process gap. The PR review checklist didn't include "did you add RLS?" so when the migration landed, no one caught it.
The Fix: Per-Affiliate Policy + Service-Role Bypass
thMenu's remediation shipped within hours of Carolina's report as PR #603 batch BBB F1:
-- supabase/migrations/20260523000005_affiliate_phase3_rls.sql ALTER TABLE public.affiliate_postback_log ENABLE ROW LEVEL SECURITY; CREATE POLICY postback_log_self_select ON public.affiliate_postback_log FOR SELECT USING (affiliate_id = auth.uid()); -- ditto for affiliate_tier_events + affiliate_1099_alerts
The policy reads as plain English: an affiliate can SELECT a row only when its affiliate_id column matches the authenticated session's auth.uid(). No WITH CHECK clause because there's no client write path — the postback dispatcher, tier-up cron, and 1099 alert cron all use service_role, which bypasses RLS by design.
Migration was applied to production within the same hour via supabase db push. Carolina re-ran her console probe; it now returned only her own rows. Cross-tenant access closed.
The Audit Process That Should Be Automatic
Three tables shipped without RLS because there was no automated check that would catch it. The post-fix audit added a defense beyond just patching the gap:
- PR review checklist: every Supabase migration that creates a new table must include a justification line — "Why is this table safe without RLS?" — or an
ENABLE ROW LEVEL SECURITYstatement. - CI assertion (planned): a static check that walks each
CREATE TABLEinsupabase/migrations/*.sqland pairs it with anENABLE ROW LEVEL SECURITYwithin the same file or a sibling migration. Mismatch fails the build. - Quarterly RLS sweep: a query against
pg_class+pg_policiesthat lists every table without RLS, run as part of the SOC2 Type-II evidence pack.
Carolina's report was the canonical disclosure cycle: an outsider with curiosity, an affiliated business reason to inspect the API, and the technical skill to recognize the result. thMenu thanked her with $500 from the responsible-disclosure pool — a token amount relative to what a malicious actor could have extracted, but a meaningful signal to the security community.
What This Means For Anyone Running A Multi-Tenant Supabase Stack
If your SaaS uses Supabase + Postgres for multi-tenant data, three actions are worth doing this week:
- Audit every existing table. Run
SELECT schemaname, tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'. Any row withrowsecurity = falseis exposed via the anon key. - Make RLS the default in migration template. Your `create-table` snippet should include the `ENABLE ROW LEVEL SECURITY` line and a per-tenant `USING` policy by default. Removing them should be a deliberate choice with a justification comment.
- Run a console probe from a real browser session. Pretend to be Carolina. Open DevTools on your own production dashboard. Try
window.__sb.from('every_sensitive_table').select('*').limit(10). Whatever comes back is the cross-tenant attack surface.
Carolina's $4,200 Q1 commission landed in her Wise account the same week the fix shipped. The competitor she would have been able to recon never knew the door was briefly open.
References: Supabase RLS docs · GDPR Art. 32 · thMenu PR #603 batch BBB F1 (this fix — three-table RLS migration + per-affiliate policies). Migration file: supabase/migrations/20260523000005_affiliate_phase3_rls.sql.
Found this helpful? Share it.
Related articles
Why Digital Menus Increase Restaurant Revenue by Up to 30%
Studies show restaurants using digital QR menus see measurable increases in aver…
When a Customer Downgrades, What Happens to Old Features? — The Silent Feature-Drift Problem in SaaS
Most SaaS apps run a single line of code when a customer downgrades — but old fe…
JWT alg-confusion attack — why Supabase's HS256 → RS256/JWKS migration breaks legacy verifiers
Verifiers that never decode the JWT header are wide open to `alg=none` and alg-c…