September 21, 2025

Hacking Thousands of Misconfigured Supabase Instances

How misconfigured Supabase APIs exposed sensitive data across thousands of organizations

Khaled Hassan

Khaled Hassan

Featured Image

Supabase Misconfigurations

  • Supabase auto-generates REST APIs from PostgreSQL tables.
  • Without Row Level Security (RLS) + policies, endpoints expose entire datasets.
  • Risk: powerful query filters (eq, neq, gt, ilike) enable data dumps.
  • Real-world finding: hundreds to thousands of instances misconfigured globally.
  • Impact: attackers can fully exfiltrate databases with trivial effort.
  • Fix: enforce RLS, strict policies, continuous testing.

Introduction. What is Supabase?

Supabase is an open-source backend-as-a-service built on top of PostgreSQL. It was created to help developers build applications quickly without losing the flexibility and reliability of a traditional relational database. At its core, Supabase provides a managed PostgreSQL instance, but it extends far beyond that by automatically generating REST and GraphQL APIs directly from the database schema. This means that as soon as you create or modify a table, the data becomes accessible through queryable endpoints.

In addition to its database and APIs, Supabase includes authentication, storage, realtime subscriptions, and serverless functions all designed to integrate seamlessly with PostgreSQL.

The philosophy of Supabase is to offer the speed and convenience of platforms like Firebase while maintaining the power, transparency, and standards of an SQL-based ecosystem. This combination has made it a popular choice among startups and developers who want to move fast while retaining full ownership and control of their data.

What happened

While conducting a penetration test on a client’s application, one particular header appeared in almost every HTTP request:

X-Client-Info: supabase-js-web/2.43.4

And this was the first good indicator that the target application database was built using Supabase, a backend-as-a-service platform that offers managed PostgreSQL databases on the cloud and this observation immediately shifted my testing mindset, since Supabase applications share a very specific architecture, where the REST API is exposed directly from the database schema.

As I dug deeper, I noticed that all the API endpoints followed a predictable pattern. The structure of requests was highly consistent, with the same rest/v1 path and SQL-like query parameters.

For example:

GET /rest/v1/users?select= HTTP/2
GET /rest/v1/keys HTTP/2
GET /rest/v1/teams HTTP/2
GET /rest/v1/organizations HTTP/2

After recognizing the pattern, I cross-referenced it with Supabase’s own documentation: Supabase REST API. Supabase advertises its auto-generated API as:

Supabase automatically generates a RESTful API from your database schema using PostgREST. This lets you interact with your database directly from the browser or alongside your own API server.

The API is instantly reflected from your schema, so any database updates are immediately available. Documentation is auto-generated in the dashboard.

It provides:

  • CRUD operations
  • Support for relationships, views, materialized views, foreign tables, and Postgres functions
  • User-defined computed columns and relationships

At first glance, this feels magical.

My reaction was literally: “Wait… does this mean I can expose my database tables as a REST API with almost zero configuration? Oh really? Wow.”

After reading further into the documentation of supabase, I came across something called the select parameter.

This parameter instantly caught my attention because it works exactly like the SELECT clause in SQL. It defines which columns and related tables you want to return in a request.

Examples:

  • select=* → return all columns.
  • select=id,name,email → return only the id, name, and email columns.
  • select=id,teams(id,name) → return the user id plus related teams data.

So in practice:

GET /rest/v1/users?select=*

is the REST API equivalent of:

SELECT * FROM users;

Coo, let’s try something.

First Attempt

I tried it directly on the endpoint:

GET /rest/v1/Users?select=* HTTP/2
PoC1

PoC1

The response? An error. At first I thought maybe the dataset was too large to be returned in one shot. I then added a limit:

GET /rest/v1/Users?select=*&limit=100
PoC2

And suddenly boom! The first 100 user records of private users information has returned.

I repeated the same approach with different endpoints. For example, the projects endpoint:

GET /rest/v1/Projects?select=*&status=eq.active HTTP/2

And this returned a full list of all active projects on the platform, whether they were intended to be private or public.

PoC3

It became clear that once the Supabase API was exposed without strict row-level security (RLS) rules.

This demonstrated a critical point: if endpoints are exposed without proper per-row access controls, it is possible to retrieve the entire database tables via the API. In other words, without the appropriate protections in place, the REST surface can be used to dump table contents that should not be public.

While experimenting, I also discovered that filters themselves could be abused. Parameters like eq, neq, gt, and ilike are designed to make querying flexible, but when authorization checks are missing, they become powerful exploitation tools.

For example:

GET /rest/v1/users?select=id,email&status=neq.disabled

or

GET /rest/v1/users?select=id,email&created_at=gt.2025-01-01

Filters can be also abused

Supabase exposes almost the full range of PostgreSQL operators through the REST API. Filters are powerful, and when authorization is missing they become effective exfiltration tools.

Examples of usable filters:

Comparison

  • eq equal, neq not equal, gt greater than, gte greater or equal, lt less than, lte less or equal

Pattern matching

  • like, ilike, match, imatch

Membership and null checks

  • in, is, isdistinct

Array and JSON

  • cs, cd, ov, sl, sr

Full text search

  • fts, plfts, phfts, wfts

Logical combinators

  • or, and, not

Examples of abuse:

GET /rest/v1/users?select=id,email&status=neq.disabled GET /rest/v1/users?select=id,email&email=ilike.%@gmail.com GET /rest/v1/users?select=id,email&id=gt.1

With no per-row restrictions, these requests behave exactly like running SQL in psql.

While investigating, I obtained a dataset of companies using Supabase. What I found was alarming. Hundreds, possibly thousands, of organizations appeared to use Supabase in ways that left them exposed to this class of issue. The list included small projects, notable startups, medium sized businesses, and some large enterprises that used Supabase for MVPs or internal tools.

Sheet

I compiled a list of affected parties and reported the issue through responsible disclosure channels. Many organizations acknowledged the report and patched the issue.

What fixes it

After reading further, I discovered that Supabase has something called RLS (Row Level Security). This is not a Supabase invention, it’s a built-in PostgreSQL feature but Supabase relies heavily on it to control what data an API client can see.

Row Level Security allows you to define rules that apply per row in a table. Instead of only granting or denying access at the table level, you can say “a user can read rows where user_id = auth.uid()” or “a user can only update rows they created.”

How it works

Row Level Security allows you to define rules that apply per row in a table. Instead of only granting or denying access at the table level, you can say “a user can read rows where user_id = auth.uid()” or “a user can only update rows they created.”

Example with the users table

In the case of a users table, you might enable RLS and then add a policy like this:

ALTER TABLE users ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Individuals can view their own profile"
  ON public.users
  FOR SELECT
  USING (id = auth.uid());

Here’s what this does:

  • ENABLE ROW LEVEL SECURITY turns RLS on for the table.
  • The USING (id = auth.uid()) part enforces that a query only returns rows where the row’s id matches the ID of the currently authenticated user.
  • auth.uid() is a Supabase helper function that extracts the user’s ID from their JWT.

What happens in practice

  • Without RLS:
GET /rest/v1/users?select=*

returns every user in the database, potentially including sensitive fields like emails, metadata, or identity provider IDs.

  • With RLS: only returns the single row that belongs to the currently logged-in user. Even if an attacker tries filters like id=gt.1 or id=neq.1234, the policy ensures that only their own row is visible.
GET /rest/v1/users?select=*

Why it’s critical

The filters themselves (eq, neq, in, gt, ilike, etc.) aren’t vulnerabilities, they’re powerful query operators. The real risk comes from APIs being exposed without RLS policies. In that situation, attackers can abuse filters to enumerate all records in the users table.

For example:

  • GET /rest/v1/users?select=id,email&status=neq.disabled → dumps all active users.
  • GET /rest/v1/users?select=id,email&email=ilike.%@gmail.com → enumerates every Gmail address.
  • GET /rest/v1/users?select=id,email&id=gt.1 → pulls every user after the first row.

Without row-level controls, the database responds exactly as if you had run these queries inside psql.

Important notes: RLS applies at the table level, not the endpoint

  • Supabase auto-generates REST endpoints for every table (/rest/v1/<table>).
  • RLS protections are enforced on the table itself, not on the REST path.
  • This means that if a developer enables RLS on users but forgets to enable it on related tables like projects or teams, those other endpoints remain exposed.
  • As a result, one table may be properly protected while another is completely vulnerable, even though both are accessible via predictable endpoints.
  • RLS also must have policies, Enabling RLS is necessary but not sufficient. If you enable RLS without defining policies, by default nobody (except privileged roles) can read or write any rows. Proper policies must be created to allow the intended access.
  • Views and functions: Even if a table has RLS, a view or function that selects from that table must also have RLS (and proper permissions) if it’s exposed. Supabase documentation specifically advises enabling RLS on all tables, views, and functions in the public schema.

Here’s a concise, safe checklist you can use to tell if a target is using Supabase. I focus on passive / non-destructive signals you can observe from the browser.

Quick indicators (what to look for)

  • X-Client-Info response/request header that contains supabase or supabase-js (for example: X-Client-Info: supabase-js-web/2.43.4).
  • Public hostnames or URLs containing the project ref and supabase.co (for example: abcd1234.supabase.co).
  • REST endpoints using the PostgREST pattern: /rest/v1/<table> or /rest/v1/ in network traffic.
  • Realtime / websocket endpoints referencing realtime or realtime.supabase.co.
  • Frontend code importing @supabase/supabase-js or referencing createClient / supabase.createClient in page source or bundled JS.
  • Public repo evidence: package.json or yarn.lock containing @supabase/supabase-js or postgrest-js.
  • SDK usage strings in JS bundles: supabaseClient, supabase.auth, supabase.from(...).

Thanks for your reading!

background
Let's hack you before real hackers do

Stay secure with DeepStrike penetration testing services. Reach out for a quote or customized technical proposal today

Contact Us