Skip to content
RowShield
All posts

Mapping a project's public surface from outside

Supabase generates an endpoint for every table, view and function in your exposed schema. We enumerate one project's entire surface with nothing but a URL and a public key.

RowShield4 min read
postgrestapi

Most teams can tell you how many endpoints they built. Very few can tell you how many they have.

This is a walk through enumerating one — a fabricated project of ours, built for the purpose. Every request below is one any visitor to a Supabase-backed application could make, using a key that is already in the client bundle.

Everything we started with

A project URL and an anon key, both taken from the deployed frontend by opening developer tools. That is the entire starting position, and it is worth sitting with for a second: this is what any visitor has.

export PROJECT="https://EXAMPLE-PROJECT.supabase.co"
export ANON="<the key from the bundle>"

Nothing here is an exploit. Every request is the documented behaviour of a correctly configured PostgREST endpoint.

The OpenAPI root, and what it lists

PostgREST publishes a machine-readable description of everything it serves. Ask for it.

curl -s "${PROJECT}/rest/v1/" -H "apikey: ${ANON}" | head -40

The response is an OpenAPI document listing every path the API exposes, and it is generated from your schema rather than written by anyone. Which means it is always complete and always current — you cannot forget to update it, and you cannot omit the table you would rather nobody knew about.

curl -s "${PROJECT}/rest/v1/" -H "apikey: ${ANON}" \
  | grep -oE '"/[a-z_]+"' | sort -u

Eleven tables we did not expect

Our fabricated application has four screens. The API listed nineteen paths.

The eleven we had not thought about fell into a pattern that will be familiar to anyone who has done this on a real project:

  • Join tables. listing_tags, booking_participants — created by the schema, never queried directly by the frontend, and every bit as exposed as anything else.
  • Tables from a feature that shipped and was rolled back. The code went; the table stayed.
  • Audit and log tables. Frequently the most sensitive things in the schema, and almost never on anyone's mental list of "the tables".
  • Tables created by an extension or a migration tool, which nobody wrote and nobody reviewed.

The important property is that none of these appeared in any inventory, because no inventory existed. The API is generated from the schema, and the schema is the only list there is.

Two views that were meant to be internal

Views are exposed exactly like tables. Ours included a reporting view built to join bookings to profiles for a dashboard, which is precisely the kind of object that gets created once, used from a trusted context, and forgotten.

The reason views deserve separate attention is that a view's relationship to row-level security depends on how it was declared. A view running with its creator's privileges evaluates the underlying tables' policies as the creator rather than as the caller — which is a legitimate and sometimes necessary tool, and also a way for rows to arrive somewhere no policy approved. A carefully protected table can be readable through a view that was never thought of as an access path.

Four functions, and what their signatures reveal

Database functions are endpoints too, reachable at /rest/v1/rpc/<name>, and the OpenAPI document names them along with their parameters.

curl -s "${PROJECT}/rest/v1/" -H "apikey: ${ANON}" | grep -oE '"/rpc/[a-z_]+"' | sort -u

Two things fall out of this that are worth more than the list itself. The parameter names describe your domain model to anyone reading — p_organisation_id, p_role — and a function's declaration determines whether the policies protecting the tables it touches apply to it at all. A function created to do one privileged thing on behalf of a trusted caller, exposed on a public endpoint, is the shortest path around an otherwise correct policy set.

What the same exercise finds on your project

Run the four commands above against your own project. The whole thing takes five minutes, and it produces two lists worth keeping.

  • The objects you did not know were public. For each, decide whether it should be reachable at all — and if not, whether it belongs in the exposed schema.
  • The objects you knew about but have not tested. For each, make an anonymous request and see what comes back. An empty array is the answer you want; confirm the table is not simply empty, or the pass means nothing.

Your schema is your API. You did not design the second one — you generated it by writing the first.

The lasting version of this is not the list. It is noticing that the list changes every time somebody writes a migration, which is the point at which "we audited our endpoints" stops being a thing you can have done and starts being a thing you do.

Run a free audit

RowShield is an independent product with no affiliation to, or endorsement from, Supabase.

RowShield checks what a deployed Supabase app exposes: a free anonymous, read-only probe, and scheduled policy-metadata and drift checks for connected projects. Run a free audit.

RowShield is a Veristria product. More about RowShield.