Security Basics

9 min

Supabase Row Level Security Audit Checklist

Supabase Row Level Security Audit Checklist

RLS enabled doesn't mean your data is protected. The failures that actually expose customer records: disabled tables, write-only gaps, spoofable tenant IDs, and exposed service-role keys.

RLS enabled doesn't mean your data is protected. The failures that actually expose customer records: disabled tables, write-only gaps, spoofable tenant IDs, and exposed service-role keys.

A Supabase row level security audit is not a paperwork exercise. It answers a harder question: can a real user, armed only with your public application and their own login, read or change records that belong to somebody else?

For a SaaS product built quickly with Supabase, the answer is often less certain than founders expect. The frontend has a public anon key by design. Tables can be queried directly through the Supabase API. AI-generated code may correctly create tables and policies that look plausible, while missing the relationship checks that actually protect customer data.

That is how an app with working authentication still ends up exposing profiles, orders, uploaded documents, internal notes or billing-related records. The screen may hide another customer's data. The database policy has to block the request too.

What a Supabase RLS audit actually checks

Row Level Security, or RLS, is PostgreSQL's mechanism for deciding which rows a database role can select, insert, update or delete. In Supabase, RLS policies commonly use the authenticated user's JWT and auth.uid() to restrict access. Used well, it is a strong control. Used partially, it creates a false sense of safety.

An audit starts by mapping the data that matters: user records, organisation data, tenant-owned objects, payment state, private files and administrative workflows. Each table needs an explicit answer to four questions: who can read it, who can create records, who can edit them, and who can delete them?

The review then tests those answers against the live API and the repository. This matters because a policy that looks sensible in a SQL migration can behave differently once the frontend submits unexpected fields, a join crosses tenant boundaries, or a server-side route uses an elevated credential.

A useful audit does not stop at "RLS enabled". It verifies that the policy enforces the access model your product is supposed to have.

The failures that create real exposure

RLS disabled on a reachable table

The clearest failure is a table in the public schema with RLS disabled and API access available to anon or authenticated roles. Depending on grants, an attacker may be able to enumerate or alter records through direct REST requests without using your interface at all.

This frequently happens when a new table is added during a product sprint, a migration is run manually, or an AI assistant generates the schema without the accompanying security statements. A forgotten table such as leads, submissions, messages or user_settings can expose more than its name suggests when it contains email addresses, account identifiers or operational notes.

Policies that only protect reads

A common policy correctly restricts SELECT with a condition such as user_id = auth.uid(), then leaves INSERT, UPDATE or DELETE broadly available. This can become an account compromise or fraud issue rather than a privacy issue.

For example, allowing users to insert rows without a WITH CHECK condition can let them create a record assigned to another user or organisation. An update policy that checks only the old row can let a user change owner_id or organisation_id to claim data, move it into a different tenant, or modify fields the client should never control.

Read and write permissions are separate decisions. A policy needs to account for both the row being accessed and the values a request is allowed to introduce.

Tenant checks that trust client-controlled fields

Marketplace and B2B products often use an organisation_id, workspace_id or team_id column. The risky pattern is trusting the value sent from the browser. If the policy says a signed-in user can insert a row where organisation_id is anything, a user can submit another tenant's identifier.

The safer design derives tenancy from a membership table or a server-controlled process, then checks that the active user is actually a member of that tenant. The exact query depends on your model. A solo-user application may safely rely on user_id = auth.uid(). A multi-tenant application usually needs a membership lookup and careful handling of roles such as owner, member and staff.

That lookup also needs its own policy review. If the membership table is writable by ordinary users, the rest of the access model can be bypassed by creating a new membership row.

Service-role keys exposed to the client

The Supabase service-role key bypasses RLS. It belongs only in a protected server environment, never in browser code, a mobile bundle, a public repository, client-side environment variables or a function that unauthenticated users can invoke freely.

This is one of the most serious findings in a Supabase deployment because an exposed service-role key can turn a narrow policy mistake into full database access. Rotating the key is necessary, but it is not the complete fix. The audit should identify where the key entered source control or a build artefact, remove it from the reachable environment, review logs where available, and replace any unsafe server route that relied on it.

Storage policies left behind

Supabase Storage has its own policy surface through the storage.objects table. A private bucket is not automatically a complete access-control strategy if object policies allow broad reads, writes or deletes.

An audit checks whether a user can guess another customer's file path, upload into a protected prefix, overwrite a document, or obtain a signed URL through an insecure API endpoint. File metadata can be sensitive too. An invoice PDF, identity document or export file should be protected by the same tenant rules as the database records that reference it.

How to perform a Supabase row level security audit

Start with the application, not just the SQL editor. Create at least two standard user accounts in separate tenants, plus any staff or administrator role your product supports. Then use the real browser traffic or direct API requests to try cross-account actions: change an object ID, swap an organisation ID, alter an owner field, request a predictable storage path, and call endpoints without a session.

This is an IDOR test in practice. If changing a UUID, slug or numeric ID lets one account access another account's object, the application has an authorisation flaw even if the page itself never offers that option.

Next, inspect schema migrations, policy definitions, grants and server-side Supabase client creation. Look for tables missing ENABLE ROW LEVEL SECURITY; permissive USING (true) or WITH CHECK (true) clauses; broad grants; security-definer functions; and service-role usage. Security-definer functions are not automatically wrong, but they need special scrutiny because they can run with elevated database privileges and bypass the caller's normal permissions.

Then compare the policy logic with product behaviour. A user may need to see orders in their organisation but not everyone in the organisation should be able to issue refunds. Support staff may need limited customer access, while an internal administrator needs a separate, logged route. RLS can enforce much of this, but sensitive actions may belong in a controlled server-side function with explicit role checks and audit logging.

Finally, test failure conditions. What happens with an expired JWT, a malformed token, an anonymous request, a deleted membership, or a user who was removed from a team but still has a valid session? Many access-control defects only appear at these boundaries.

What good remediation looks like

The fix is rarely "turn RLS on" and move on. Enabling RLS without policies can break production features, while adding broad policies simply restores the exposure under a different name. Remediation should be specific to each data path.

For a user-owned table, that may mean adding restrictive select, update and delete policies tied to auth.uid(), plus an insert check that prevents ownership spoofing. For a tenant-owned table, it may mean creating a tightly controlled membership predicate and preventing clients from changing tenancy columns. For administrative actions, it may mean removing direct browser access altogether.

Treat database migrations as deployable security controls. Keep policy changes in version control, review them with the same care as payment logic, and test them with two-user scenarios before release. If your application uses generated types or an ORM layer, remember that types improve developer experience but do not enforce authorisation at runtime.

When automated checks are not enough

Automated tooling can flag missing RLS, exposed secrets, risky dependencies and obvious policy gaps quickly. That is valuable, especially when a small team is shipping fast. It cannot reliably decide whether organisation_id ownership is correct for your business model, whether a support workflow is appropriately scoped, or whether an AI-generated route turns a service-role key into a public privilege-escalation endpoint.

Those questions require manual review of the repository, live application behaviour and the intended rules of the product. HollowByte combines both: automated checks to find broad exposure quickly, followed by targeted testing of RLS, JWT handling, IDOR paths, API routes and credentials. The useful output is a severity-rated set of findings with affected files, proof of the risk and the exact corrective work required.

Your app does not need a large security team before it needs clear boundaries around customer data. If you have shipped quickly, added multi-tenancy, or let AI generate part of the database layer, verify those boundaries while the fix is still a contained engineering task rather than a customer incident.

Back to blog