Hide Your Exposed Supabase API Schema
One of the features of Supabase is a REST interface to your database. This automatically generates an OpenAPI spec file and serves it up on the API itself. This lets anyone who knows your Supabase anon key (basically anyone using your web app that accesses Supabase) see all of it. From this they can reverse engineer your entire database schema!
I don’t need to use any tools that consume this spec file; They all use the schema from the database itself. My goal is to close this information leak.
I will demonstrate the issue using a simple little demo app I made to try things out, so I’m ok exposing the schema. For my day job, the DB schema is part of our proprietary intellectual property, which is why I came up with this solution to protect it.
What’s Exposed? #
Let’s take a look at what you can see. I’m using a local development instance of the database so it doesn’t need the anon key, but if you’re referencing it on a cloud instance of Supabase, you will need to pass that to cURL as well.
curl http://localhost:54321/rest/v1/ > rest_api_autogenerated.json
I will examine the output using the jq
utility. You can also just open it in an editor and skim through it. It is fairly straight forward.
First, let’s get a look at what tables there are. I have two in this app:
% jq '.paths|keys' rest_api_autogenerated.json
We see an array of four endpoints: one for the query we just ran on /
, my exposed RPC function, and one per table:
[
"/",
"/rpc/do_something",
"/submissions",
"/user_metadata"
]
Since the goal here is to demonstrate the schema extraction, we’ll ignore the actual operations on those tables, and just find the object definitions. Let’s look at the user_metadata
table.
jq .definitions.user_metadata rest_api_autogenerated.json
Which shows us the whole table details:
{
"description": "Tracks and enforces usage count of submissions",
"required": [
"user_id",
"usage_count",
"is_anonymous"
],
"properties": {
"user_id": {
"description": "Note:\nThis is a Primary Key.<pk/>",
"format": "uuid",
"type": "string"
},
"usage_count": {
"default": 0,
"format": "integer",
"type": "integer"
},
"is_anonymous": {
"default": true,
"format": "boolean",
"type": "boolean"
}
},
"type": "object"
}
This is the table as defined within Postgres itself:
postgres=> \d user_metadata
Table "public.user_metadata"
Column | Type | Collation | Nullable | Default
--------------+---------+-----------+----------+---------
user_id | uuid | | not null |
usage_count | integer | | not null | 0
is_anonymous | boolean | | not null | true
Indexes:
"user_metadata_pkey" PRIMARY KEY, btree (user_id)
As you can see, the REST API auto-generated OpenAPI spec has given up the entire table structure. The only things missing are the CONSTRAINTS
and other indexes. This would be quite valuable if you were going to try to reverse engineer how my business works.
The API does not reveal the code for my functions, but it does reveal they exist and their parameters. Trigger functions are not revealed.
jq '.paths."/rpc/do_something".get.parameters' rest_api_autogenerated.json
We see the input parameter, user_id
is of type uuid
. The output is always just defined as JSON without details, so at least that is kept private.
[
{
"format": "uuid",
"in": "query",
"name": "user_id",
"required": true,
"type": "string"
}
]
The GraphQL endpoint also leaks information similarly and is enabled by default.
Querying Your Cloud Instance #
To query your cloud instance, you need to pass the Supabase anon key.
curl https://YOUR_PROJECT_ID.supabase.co/rest/v1/ -H "apikey: eyJhbGciOiJIUzI1NiIsIn...1OouXJEwWFxE8w"
What To Do About It? #
What we’re going to do is tell the REST API to replace the auto-generated spec file with a tiny little stub and turn off the unused GraphQL endpoint.
I created this migration file with npx supabase migration new hide_generated_api_schema
:
-- hide the auto-generated API schema at the /rest/v1/ endpoint
CREATE OR REPLACE FUNCTION pg_rest_root() RETURNS JSON AS $_$
DECLARE
openapi JSON = $${"swagger": "2.0","info":{"title":"Private API","description":"This is not a public API. Stop snooping."}}$$;
BEGIN
RETURN openapi;
END
$_$ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE SET search_path = '';
COMMENT ON FUNCTION pg_rest_root() IS 'Hide the OpenAPI schema for the /rest/v1/ endpoint.';
ALTER ROLE authenticator SET pgrst.db_root_spec = 'public.pg_rest_root';
NOTIFY pgrst, 'reload config';
-- remove the graphql extension since we don't use it and it exposes more API details
DROP EXTENSION IF EXISTS pg_graphql;
This overwrites the variable pointing to the function that generates the spec with our own. Once we apply it, we can see the result is just what we asked it to be instead of the full details of our database.
% npx supabase migration up
Connecting to local database...
Applying migration 20250114170421_hide_generated_api_schema.sql...
Local database is up to date.
% curl http://localhost:54321/rest/v1/ | jq .
The schema now reports as:
{
"swagger": "2.0",
"info": {
"title": "Private API",
"description": "This is not a public API. Stop snooping."
}
}
This reduces the number of ways someone can figure out your attack surface.