Security Basics

9 min

How to Fix Public Supabase Database Access

How to Fix Public Supabase Database Access

A public Supabase database is usually RLS disabled, a permissive policy copied from a tutorial, or an endpoint that trusts a user-supplied ID. How to find it, lock it down, and verify the fix holds.

A public Supabase database is usually RLS disabled, a permissive policy copied from a tutorial, or an endpoint that trusts a user-supplied ID. How to find it, lock it down, and verify the fix holds.

A public Supabase database is not always caused by a stolen key. More often, it is a table in the public schema with Row Level Security disabled, a permissive policy copied from a tutorial, or an API endpoint that trusts a user-supplied record ID. To fix public Supabase database access, you need to establish what is exposed, stop unsafe requests, and then test the rules as an attacker would.

The risk is operational, not theoretical. An exposed profiles, orders, messages, or subscriptions table can leak customer details, enable account-level data theft, or give a competitor a view of activity that should be private. If your app was assembled quickly with AI assistance, assume a configuration that “made it work” may have also made it queryable.

First, identify what is actually public

Supabase has two different concepts that are frequently confused. The public schema is a standard PostgreSQL schema name. It does not automatically mean that every table is available to everyone. The real control points are database grants, Row Level Security (RLS) and the policies attached to each table.

Likewise, the Supabase anon key is designed to appear in a browser application. Its presence in a frontend bundle is not, by itself, a breach. It becomes dangerous when your RLS policies allow the anon or authenticated role to read or change records they should not reach.

Start with an inventory. In the Supabase dashboard, review every table that holds user, business, payment, internal, or operational data. Check whether RLS is enabled. Then inspect each policy, including policies created for SELECT, INSERT, UPDATE and DELETE.

Do not rely on the dashboard alone. Test your live application without signing in, then with two ordinary test accounts. Account A must never be able to retrieve, edit, or delete Account B's records simply by changing an ID in a request, URL, or client-side query. That is an insecure direct object reference, or IDOR, and it remains common in Supabase-backed products.

If you have query logging enabled, review recent database and API activity for unfamiliar patterns: large reads, repeated sequential IDs, requests against tables the frontend should not use, and access from unexpected origins. Logs cannot always prove whether data was taken, but they help set the scope and urgency.

Contain exposure before redesigning the schema

If you find a sensitive table with RLS disabled or an obviously open policy, contain it immediately. Enable RLS, remove permissive policies, and temporarily disable the affected product feature if you cannot establish a safe policy straight away. A short interruption is preferable to leaving customer data available while a fix is debated.

For a table that should only expose records to their owner, the baseline is usually straightforward:

alter table public.projects enable row level security;

create policy "Users can read their own projects"
on public.projects
for select
to authenticated
using (owner_id = auth.uid());

create policy "Users can create their own projects"
on public.projects
for insert
to authenticated
with check (owner_id = auth.uid());

create policy "Users can update their own projects"
on public.projects
for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());

create policy "Users can delete their own projects"
on public.projects
for delete
to authenticated
using (owner_id = auth.uid())

alter table public.projects enable row level security;

create policy "Users can read their own projects"
on public.projects
for select
to authenticated
using (owner_id = auth.uid());

create policy "Users can create their own projects"
on public.projects
for insert
to authenticated
with check (owner_id = auth.uid());

create policy "Users can update their own projects"
on public.projects
for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());

create policy "Users can delete their own projects"
on public.projects
for delete
to authenticated
using (owner_id = auth.uid())

alter table public.projects enable row level security;

create policy "Users can read their own projects"
on public.projects
for select
to authenticated
using (owner_id = auth.uid());

create policy "Users can create their own projects"
on public.projects
for insert
to authenticated
with check (owner_id = auth.uid());

create policy "Users can update their own projects"
on public.projects
for update
to authenticated
using (owner_id = auth.uid())
with check (owner_id = auth.uid());

create policy "Users can delete their own projects"
on public.projects
for delete
to authenticated
using (owner_id = auth.uid())

This is a pattern, not a policy you should paste blindly. The correct ownership column might be user_id, and a workspace product may need membership-based access rather than direct ownership. The key point is that both the existing row and any new value must be authorised. An update policy with only using can still allow a user to reassign a record's ownership unless with check also restricts the new row.

Where possible, make ownership fields difficult to manipulate from the client. A client should not be free to submit another user's UUID and hope the policy catches it. Set the owner server-side, validate it in a controlled database function, or enforce it with a policy that requires owner_id = auth.uid().

Fix public Supabase database access with least privilege

RLS is the primary protection for browser-accessible Supabase tables, but it is not the only control. Review who has grants on each table, sequence, view and function. A table can have sensible policies but still be exposed through a view or a security definer function written without careful authorisation checks.

Use the anon role only where an unauthenticated visitor genuinely needs access. Marketing content, a public directory, or a deliberately public catalogue may justify it. Customer profiles, support tickets, invoices, admin notes, internal flags, and webhook event records generally do not.

For multi-tenant applications, write policies around tenant membership rather than trusting a tenant_id received from the browser. For example, a user should read a workspace's projects only if their identity appears in a membership table for that workspace. That membership lookup must itself be protected so users cannot create or alter memberships without authority.

Administrative actions deserve a separate path. Do not solve admin access by creating a policy that permits any authenticated user to read all rows. Use a verified role or a controlled server-side endpoint. Keep the service-role key on a server you control, in environment variables, never in a client bundle, mobile app, repository, screenshot, or support message. The service role bypasses RLS. Treat it as a database administrator credential.

If that key has appeared anywhere public or in an exposed repository, rotate it. Then find every deployment, serverless function, background job and integration that uses it before the rotation causes a production failure. Rotation without an inventory often swaps a security incident for an outage.

Check the paths around the table

A secure table can still leak through adjacent application code. Review API routes, edge functions, RPC calls and server actions that query Supabase using the service role. These endpoints must authenticate the caller and verify that the caller is entitled to the requested resource. Never accept user_id, account_id, organisation_id, or is_admin from the browser as proof of permission.

Also inspect storage buckets. Private database rows paired with a public bucket can expose uploaded identity documents, exports, attachments, or generated reports. Use private buckets for customer files and issue signed URLs only after an authorisation check. Public buckets are appropriate only for assets you intend anyone to fetch.

Payment and webhook tables need particular care. A user should not be able to read raw payment provider payloads, insert a fabricated “paid” state, or call a function that updates subscription status. Webhook verification should happen server-side, and the resulting records should be writable only by trusted backend processes.

Verify the repair with hostile tests

A policy that looks correct can fail in a specific query shape. Verification is where many quick fixes stop too early. Create two disposable accounts and test the behaviour through the same API and frontend paths your customers use.

Try direct reads by guessed UUID and sequential values. Try filtering, pagination, nested selects, inserts with another account's ID, ownership changes during updates, and deletes. Try calling RPC functions directly. Test as an unauthenticated visitor too. Your expected result should be either no rows or a permission error, not a partial response containing someone else's data.

Repeat this after schema migrations. New tables may ship with RLS off, and AI-generated migrations can create convenient but unsafe policies such as using (true) or with check (true). Treat every new table as private until a specific product requirement proves otherwise.

Make access reviews part of release work

The lasting fix is a release habit, not a one-off dashboard change. Before deploying a new feature, ask which tables it touches, which roles need access, whether access is per user or per workspace, and whether a server-side action bypasses RLS. Document the intended rule in the pull request alongside the migration.

A focused security review should inspect the live application and repository together. That is how you catch the gap between a correct-looking RLS policy and a service-role endpoint that bypasses it. HollowByte reviews Supabase configuration, API behaviour, exposed credentials and IDOR paths, then provides severity-rated findings and exact remediation work.

Your application can move quickly without making its database available to whoever opens browser developer tools. Make each data path prove who is asking, what they own, and why they are allowed to act.

Back to blog