Back

Three Unauthenticated Ways to Introspect a Supabase Database Schema

10 min read
supabase postgrest graphql schema-introspection security-research supascan

What is Supabase

Supabase is an open-source backend platform built on top of Postgres. The core idea is that your frontend application talks directly to the database through an auto-generated REST API powered by PostgREST. You create tables in Postgres, and PostgREST immediately exposes them as RESTful endpoints. Supabase wraps this with authentication (GoTrue), file storage, edge functions, and a client SDK that makes it feel like a single product.

Because clients query the database directly, access control happens at the database level through Postgres Row Level Security (RLS). Developers write RLS policies that define which rows each user can read, insert, update, or delete. Every table exposed through the API should have RLS enabled with appropriate policies.

In practice, RLS is difficult to get right. Policies interact with each other in non-obvious ways, it’s easy to forget to enable RLS on a new table, and testing coverage is limited. This is especially true in the modern development world of AI. While agents are good at writing policies, it’s very easy to ship fast and forget these fundamental principles when developing your backend. Since Supabase databases are intended to be exposed to the frontend, I was curious: how much can we learn about a database from the POV of an attacker? For this, I built supascan, a CLI tool that automatically audits a Supabase database given just the URL and an API key.

The Problem: Schema Discovery

The first thing supascan needs to do is figure out what exists in the database. To audit RLS policies and check for exposed data, it needs to know:

  • Schemas: the Postgres schemas exposed through the API (e.g. public, storage, custom schemas)
  • Tables: the tables within each schema
  • RPC functions: server-side functions callable via /rpc/<name>

Usually when developing with Supabase, users know their own tables and can write queries like:

await supabase.from("table").select("*");

Developers can also take advantage of Supabase’s extremely powerful type generation to get intellisense on tables, columns, RPCs, and arguments. However, from an outsider’s perspective, Supabase doesn’t have a single dedicated API for schema introspection. Other than naive brute-forcing of table names, there are three different mechanisms that I’ve found, each with different access requirements. This post covers all three and how supascan uses them.

The source code is in the supascan repo.

Discovering Schemas

Before you can enumerate tables, you need to know which schemas are exposed. PostgREST will tell you through its error messages. If you request a schema that doesn’t exist, the error response lists the valid ones:

// From supascan packages/core/src/supabase.ts
export async function* getSchemas(
client: SupabaseClient,
nonexistantSchema = "NONEXISTANT_SCHEMA_THAT_SHOULDNT_EXIST",
): AsyncGenerator<SupabaseEvent, Result<string[]>> {
const { data, error } = await client
.schema(nonexistantSchema)
.from("")
.select();
// Parse schema names from the error message
const fromMessage =
error?.message
.split("following: ")[1]
?.split(",")
.map((schema) => schema.trim()) ?? [];
const fromHint =
(error as { hint?: string })?.hint
?.split("exposed: ")[1]
?.split(",")
.map((schema) => schema.trim()) ?? [];
const schemas = fromMessage.length > 0 ? fromMessage : fromHint;
return ok(schemas);
}

PostgREST returns something like: "The schema must be one of the following: public, storage, graphql_public". The code parses that string to extract the list. This works regardless of which introspection method you use after.

Method 1: OpenAPI / Swagger

PostgREST automatically generates an OpenAPI (Swagger 2.0) specification for every schema it exposes. When you query the root of the REST API with a schema selected, PostgREST returns the spec instead of data.

This is the original method supascan used. It works through the standard Supabase client:

// From supascan packages/core/src/supabase.ts
async function getSwagger(
client: SupabaseClient,
schema: string,
): Promise<Result<SupabaseSwagger>> {
const { data, error } = await client.schema(schema).from("").select();
if (!error && data) {
return ok(data as unknown as SupabaseSwagger);
}
return err(error ?? new Error("Failed to fetch schema"));
}

The response is a Swagger 2.0 document where every table is a path (/tablename) and every RPC is at /rpc/funcname. Extracting tables and RPCs is just filtering the path keys:

// Tables: paths that don't start with /rpc/
const tables = Object.keys(swagger.paths)
.filter((key) => !key.startsWith("/rpc/"))
.map((key) => key.slice(1))
.filter((key) => !!key);
// RPCs: paths that start with /rpc/
const rpcs = Object.keys(swagger.paths)
.filter((key) => key.startsWith("/rpc/"))
.map((key) => key.slice(1));

The Swagger spec also includes parameter definitions for RPC functions, which supascan uses to discover function signatures:

// From supascan packages/core/src/supabase.ts
export async function getRPCsWithParameters(
client: SupabaseClient,
schema: string,
): Promise<Result<RPCFunction[]>> {
const swaggerResult = await getSwagger(client, schema);
if (!swaggerResult.success) return err(swaggerResult.error);
const rpcFunctions: RPCFunction[] = [];
Object.entries(swaggerResult.value.paths).forEach(
([path, methods]: [string, any]) => {
if (path.startsWith("/rpc/")) {
const postMethod = methods.post;
if (postMethod && postMethod.parameters) {
const parameters: RPCParameter[] = [];
postMethod.parameters.forEach((param: any) => {
if (param.in === "body" && param.schema?.properties) {
const requiredParams = param.schema.required || [];
Object.entries(param.schema.properties).forEach(
([paramName, paramDef]: [string, any]) => {
parameters.push({
name: paramName,
type: paramDef.type || "unknown",
format: paramDef.format,
required: requiredParams.includes(paramName),
});
},
);
}
});
rpcFunctions.push({ name: path.slice(1), parameters });
}
}
},
);
return ok(rpcFunctions);
}

Limitations: Supabase has been restricting access to the OpenAPI spec. On newer projects using publishable keys, the spec is no longer accessible with an anon key. This is what led me to look for additional methods that worked universally on all Supabase projects.

Method 2: Schema Cache (/rest-admin/v1/schema_cache)

PostgREST maintains an internal schema cache that it uses to map API requests to SQL. This cache is accessible through the admin server at /rest-admin/v1/schema_cache, and it returns a JSON dump of all known tables and routines. Note: this is not always exposed, but on Supabase Cloud instances this endpoint is accessible.

In fact, I found this endpoint while researching PostgREST internals for supascan, and it ended up being the starting point for a separate vulnerability I reported.

The schema cache returns a JSON object with dbTables and dbRoutines arrays. Each entry includes the schema name and object name:

// From supascan packages/core/src/supabase.ts
async function fetchSchemaCache(
client: SupabaseClient,
schema: string,
): Promise<Result<SupabaseSwagger>> {
const { supabaseUrl, supabaseKey } = client as unknown as {
supabaseUrl: string;
supabaseKey: string;
};
const res = await fetch(`${supabaseUrl}/rest-admin/v1/schema_cache`, {
headers: { apikey: supabaseKey },
});
if (!res.ok) {
return err(new Error(`schema_cache fetch failed: ${res.status}`));
}
const cache = (await res.json()) as {
dbTables?: Array<
[
{ qiSchema: string; qiName: string },
{ tableName: string; tableSchema: string },
]
>;
dbRoutines?: Array<[{ qiSchema: string; qiName: string }, unknown]>;
};
const paths: Record<string, unknown> = {};
for (const [meta] of cache.dbTables ?? []) {
if (meta.qiSchema === schema) {
paths[`/${meta.qiName}`] = { get: {}, post: {} };
}
}
for (const [meta] of cache.dbRoutines ?? []) {
if (meta.qiSchema === schema) {
paths[`/rpc/${meta.qiName}`] = { post: {} };
}
}
return ok({ swagger: "2.0", paths } as SupabaseSwagger);
}

Supascan normalizes the schema cache output into the same Swagger-like format that Method 1 produces, so the rest of the pipeline (table enumeration, RLS testing, etc.) works the same way regardless of which method discovered the tables.

Access requirements: The /rest-admin/v1/schema_cache endpoint is on PostgREST’s admin server. While this is currently exposed, I can definitely see this endpoint being removed from public access in the future.

Method 3: GraphQL Introspection

Supabase exposes a GraphQL API at /graphql/v1 powered by the pg_graphql extension. GraphQL has a built-in introspection system that returns the full schema, and this is another way to discover tables and functions. On newly created Supabase Cloud projects, graphql_public is enabled by default.

The request goes through the gateway to PostgREST, which calls the graphql_public.graphql() function, which delegates to the pg_graphql Rust extension to resolve queries.

A full introspection query:

#!/usr/bin/env python3
import requests, json, sys
url = sys.argv[1] # https://<project>.supabase.co
key = sys.argv[2] # anon key
query = """
{
__schema {
queryType { name }
mutationType { name }
types {
name
kind
description
fields {
name
description
type {
name
kind
ofType { name kind ofType { name kind ofType { name kind } } }
}
args {
name
type {
name
kind
ofType { name kind ofType { name kind } }
}
}
}
inputFields {
name
type {
name
kind
ofType { name kind }
}
}
}
}
}
"""
headers = {"apikey": key, "Content-Type": "application/json"}
resp = requests.post(f"{url}/graphql/v1", headers=headers, json={"query": query})
data = resp.json()

The response contains every type in the schema. Tables appear as OBJECT types with their columns as fields. RPC functions show up as query/mutation fields. To extract them:

schema = data["data"]["__schema"]
types = schema["types"]
tables = []
for t in types:
name = t["name"]
kind = t["kind"]
if name.startswith("__"):
continue
if kind == "OBJECT":
if name.endswith("Connection"):
continue
if name in ["Query", "Mutation", "PageInfo"]:
continue
if "Response" in name:
continue
tables.append(t)

Each table type includes its fields with full type information. A helper function resolves the nested GraphQL type structure into readable signatures:

def get_type_str(t):
if not t:
return "?"
name = t.get("name")
kind = t.get("kind")
if name:
return name
if kind == "NON_NULL":
return f"{get_type_str(t.get('ofType'))}!"
if kind == "LIST":
return f"[{get_type_str(t.get('ofType'))}]"
return kind or "?"

This converts nested type objects into signatures like String!, [Int], [Account!]!.

Query and mutation fields (which include RPC functions) can be extracted from the Query and Mutation types:

query_type = next((t for t in types if t["name"] == "Query"), None)
if query_type and query_type.get("fields"):
for f in query_type["fields"]:
args = ", ".join(
f"{a['name']}: {get_type_str(a['type'])}"
for a in f.get("args", [])
)
ret = get_type_str(f["type"])
print(f" {f['name']}({args}): {ret}")

You can also target a specific schema by setting the Content-Profile header:

headers["Content-Profile"] = "storage" # introspect the storage schema

Access requirements: The GraphQL endpoint is accessible with the anon key. Introspection is enabled by default. The schema returned respects permissions, so you see the types that the current role has access to.

Comparison

MethodEndpointReturns
OpenAPI/rest/v1/Swagger 2.0 spec with tables, RPCs, parameters
Schema Cache/rest-admin/v1/schema_cacheRaw PostgREST internal cache (tables + routines)
GraphQL/graphql/v1GraphQL metadata with fields, args, types

The OpenAPI method gives the most structured output (Swagger spec with parameter types and descriptions) but access is being restricted on newer deployments. The schema cache gives a raw dump of PostgREST’s internals but requires admin endpoint access. GraphQL introspection is the most standard intended usage of the API, however it requires graphql_public to be exposed.

Supascan tries the OpenAPI method first and falls back to the schema cache. The GraphQL method implementation is TBD. Supascan is very reliable on Cloud instances primarily due to the schema cache being readily available.

Running supascan

Run against a target directly:

Terminal window
bunx supascan -u https://<project>.supabase.co -k <anon-key> --html

Or extract credentials from a website automatically:

Terminal window
bunx supascan -x https://supabase.com --html

Supascan discovers schemas, enumerates tables, tests RLS policies, checks RPC functions, and generates an interactive HTML report.


The full source code is at github.com/abhishekg999/supascan. If you have questions, feel free to reach out.

Related