Three Row Level Security Pitfalls That Leak Data in Supabase
If your marketing app runs on Supabase, Row Level Security is the line between “our team can see campaign data” and “any logged-in user can pull another account’s leads.” I care about this because paid and organic programs create messy, valuable tables: leads, utm_touchpoints, ad_accounts, email_events, stripe_customers, content_briefs. A single policy mistake can expose the […]
If your marketing app runs on Supabase, Row Level Security is the line between “our team can see campaign data” and “any logged-in user can pull another account’s leads.” I care about this because paid and organic programs create messy, valuable tables: leads, utm_touchpoints, ad_accounts, email_events, stripe_customers, content_briefs. A single policy mistake can expose the exact stuff a founder would never paste into a pitch deck: CAC by channel, trial emails, partner payouts, and the 312 leads from last Tuesday’s LinkedIn campaign.
Supabase makes browser-to-Postgres access practical. That’s the bargain. With @supabase/supabase-js 2.111.0, verified on npm on August 17, 2026, a Next.js or Remix app can query Postgres directly from the client with a publishable key. Supabase’s own docs say that publishable keys are safe to expose only because access is guarded by Postgres grants, RLS policies, and the user’s JWT. The key is not the lock. Your policies are.
I see three leaks come up in operator-built apps. They don’t look dramatic in code review. They look like a dashboard shipping fast, a Zapier replacement getting wired over a weekend, or a founder adding “just one admin report” at 11:40 p.m. before a board update.
1. Treating public like a private workspace
Supabase’s Data API exposes schemas, and the default schema people use is public. In older projects, tables created in public commonly received SELECT, INSERT, UPDATE, and DELETE grants for anon, authenticated, and service_role. Supabase announced a platform change in 2026: starting May 30, 2026, new projects default toward explicit grants when “Automatically expose new tables” is unchecked. That change helps new builds. It does not magically clean up the B2B waitlist app you created in March 2024.
The leak starts with a normal marketing table:
create table public.leads (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null,
email text not null,
source text not null,
campaign_id text,
created_at timestamptz default now()
);
If RLS is off and authenticated has SELECT, every signed-in user can query rows in public.leads through /rest/v1/leads. They don’t need your admin UI. Chrome DevTools, curl, or the Supabase client is enough. I have seen this exact shape in small growth tools where the frontend filters by workspace_id, so the page looks correct while the API remains wide open.
Client filters are not authorization. This line is a UI preference, not a security boundary:
const { data } = await supabase
.from('leads')
.select('*')
.eq('workspace_id', activeWorkspaceId)
The browser sends activeWorkspaceId. A curious contractor can change it. A competitor with a trial account can loop through UUIDs from leaked analytics URLs. A founder testing from the Supabase SQL editor can miss the issue because the dashboard query uses a privileged role and returns exactly what they expected.
The fix is boring, which is why it works. Keep internal tables out of exposed schemas when you can. Put API-facing objects in a schema named api, keep raw attribution and billing tables in private, and grant only the roles that need access. For tables that must be exposed, enable RLS in SQL, not from memory after launch.
alter table public.leads enable row level security;
create policy "workspace members can read leads"
on public.leads
for select
to authenticated
using (
exists (
select 1
from public.workspace_members wm
where wm.workspace_id = leads.workspace_id
and wm.user_id = (select auth.uid())
)
);
Run the test as the role that matters. In Supabase, anon and authenticated are Postgres roles used by the Data API. A signed-in user with a publishable key maps to authenticated, while a visitor without a session maps to anon. On August 17, 2026, Supabase’s docs still describe grants and RLS as two separate layers: grants decide whether a role can reach the table, and RLS decides which rows that role can see.
For marketing teams, I add one regression test per revenue-shaped table. The test signs in as user_a, asks for workspace_b leads, and expects 0 rows. I do this for leads, contacts, campaign_spend, and crm_notes. Four tests catch more real leaks than a 20-page security checklist sitting in Notion.
2. Letting users write the claims your policy trusts
Supabase gives you auth.uid() and auth.jwt() inside policies. That’s useful. It is also where a lot of teams smuggle trust into the wrong place. Supabase’s RLS docs warn that raw_user_meta_data can be updated by the signed-in user through supabase.auth.update(), while raw_app_meta_data is the safer place for authorization data because users cannot update it directly.
This policy looks tidy during a rushed launch:
create policy "team can read contacts"
on public.contacts
for select
to authenticated
using (
workspace_id = ((auth.jwt() -> 'user_metadata' ->> 'workspace_id')::uuid)
);
It also trusts a value the user may control. If your onboarding flow stores workspace_id in user metadata because it was convenient in April, the policy has turned a profile field into a badge reader. Change the profile, change the rows. For a founder-led SaaS with 42 customers, that might expose every HubSpot import tied to a workspace UUID.
Use membership rows for authorization. They are inspectable, auditable, and harder to accidentally mutate from a settings page. A workspace_members table with workspace_id, user_id, role, and created_at gives Postgres something real to check.
create policy "members read contacts"
on public.contacts
for select
to authenticated
using (
exists (
select 1
from public.workspace_members wm
where wm.workspace_id = contacts.workspace_id
and wm.user_id = (select auth.uid())
)
);
The second half of this pitfall is WITH CHECK. Supabase’s RLS docs note that if no WITH CHECK expression is defined, Postgres reuses the USING expression for inserts and updates. That can be fine. It can also hide a broken write path when your read rule and write rule should differ.
Imagine a table called campaign_assets with rows for Google Ads images, Meta thumbnails, and organic blog graphics. Members can read all assets in a workspace, but only owners should insert rows into that workspace. If you use one broad USING clause and call it done, a viewer may be able to create assets attached to a campaign they should only inspect.
Split the policies so the intent survives the next migration:
create policy "members read campaign assets"
on public.campaign_assets
for select
to authenticated
using (
exists (
select 1 from public.workspace_members wm
where wm.workspace_id = campaign_assets.workspace_id
and wm.user_id = (select auth.uid())
)
);
create policy "owners create campaign assets"
on public.campaign_assets
for insert
to authenticated
with check (
exists (
select 1 from public.workspace_members wm
where wm.workspace_id = campaign_assets.workspace_id
and wm.user_id = (select auth.uid())
and wm.role in ('owner', 'admin')
)
);
That distinction matters in growth tooling because write access is leverage. A bad insert policy can poison attribution, add fake conversions, or attach a competitor’s pixel ID to an internal campaign. The first time I saw this, the leak was not a public data dump. It was a silent campaign report with 18 bogus conversions, and nobody noticed until the Stripe MRR export disagreed with the dashboard.
3. Forgetting that views, functions, Storage, and secret keys have their own rules
RLS lives in Postgres, but your Supabase app is not only raw table reads. Marketing apps collect files, call RPC functions, render summary views, send Edge Function webhooks, and run cron jobs for enrichment with Clearbit, Apollo, or a homegrown scraper. Each surface can bypass or sidestep the policy you lovingly wrote for public.leads.
Views are the classic miss. Supabase’s docs point out that Postgres views use the creator’s permissions by default, and views created by a privileged owner can bypass underlying RLS unless you change the security mode. Postgres 15 added security_invoker = true, which makes the view run with the permissions of the caller.
A risky report view looks harmless:
create view public.campaign_lead_counts as
select workspace_id, campaign_id, count(*) as leads
from public.leads
group by 1, 2;
If that view is exposed to authenticated and runs with elevated rights, a customer may not see individual emails, but they can see another company’s campaign IDs and lead counts. For a bootstrapper watching Google Ads spend at $420 a day, that leak is enough to reveal which campaigns are working.
On Postgres 15 or newer, create the view as an invoker view:
create view public.campaign_lead_counts
with (security_invoker = true)
as
select workspace_id, campaign_id, count(*) as leads
from public.leads
group by 1, 2;
On older Postgres projects, revoke access from anon and authenticated, or move the view into an unexposed schema. I prefer an api schema for safe views and a private schema for raw tables. The name itself helps during review. If a migration adds private.enrichment_raw_json to exposed schemas, it looks wrong in the diff.
Functions deserve the same suspicion. Supabase’s database function docs recommend security invoker, which is also the default. A SECURITY DEFINER function can be valid for admin work, but it should read like a loaded tool in code review. Set search_path, keep the body narrow, and revoke EXECUTE from anon unless the function is truly public.
Storage has its own trap. Supabase Storage uses RLS policies on storage.objects, and its ownership docs say objects created with the service_key or through the Dashboard may not get a normal owner. The old owner field is deprecated, and owner_id is the field Supabase points people to now. If your app stores sales-call recordings, exported CSVs, or UGC from a creator campaign, don’t make the bucket public because “the filenames are unguessable.” A URL that lands in Slack, Gmail, or a logged analytics event will travel.
Secret keys are the bluntest version of this problem. Supabase’s current key model has publishable keys like sb_publishable_... for public clients and secret keys like sb_secret_... for backend code. Legacy anon and service_role keys can still exist, and Supabase says legacy keys remain valid until you disable them. Secret and service_role keys bypass RLS. They belong in server-only environment variables, not NEXT_PUBLIC_ anything, not a Retool query pasted from staging, and not a Vercel client bundle.
There is one wrinkle that catches experienced teams. In an April 28, 2026 Supabase troubleshooting note, the service-role client can still return RLS-shaped surprises when the Authorization header is overwritten by a user session. The apikey header and the Authorization header are different. For admin jobs, I keep a separate supabaseAdmin client created with @supabase/supabase-js, no cookie-bound SSR helper, no shared browser session, and no auth calls that might replace the header.
The review I use before shipping is small. From a clean browser profile, create two workspaces, two users, two campaigns, and two files. Then test the REST endpoint, the Supabase client, every RPC function, every report view, and the Storage object URL as anon, user_a, user_b, and the backend admin client. If user_a can infer user_b has 87 leads from a Google Ads campaign named brand_us_q3, I treat that as a data leak even when no email address appears.
RLS is not a checkbox in Supabase. It is the contract between your product and the people trusting you with their pipeline. Marketing operators notice when a dashboard is fast, but they remember when a CSV leaks. Founders remember that too, usually right after the customer forwards a screenshot with a competitor’s campaign name in it.
Newsletter
Get growth playbooks in your inbox.
Practical SEO, PPC, automation, and web strategy from the Micromarketing team. No fluff, unsubscribe anytime.
