Your database has a second public door at /graphql/v1
Most checklists for a Supabase GraphQL endpoint security review focus on the REST API at /rest/v1/. But pg_graphql exposes the same tables a second way, at /graphql/v1, reachable with the same anon key that ships in your client bundle. If you tested table access only through REST, you tested one of two front doors.
There are two separate things to separate here, because they fail differently.
So the concrete launch risk is twofold. If RLS is missing on a table, GraphQL is another way to read its data. If RLS is correct, introspection still hands an anonymous visitor a map of your data model. The first is a data-exposure problem. The second is information disclosure, not a breach by itself.
- Data access through GraphQL runs through the same Postgres role and the same Row Level Security policies as REST. A table with RLS off, or with a permissive USING (true) policy, returns its rows to the anon caller through GraphQL exactly as it does through REST. This is observable: a query comes back with data.
- Schema disclosure is different. GraphQL introspection is on by default, so the endpoint will describe your schema, table names, column names, and relationships, even for tables whose rows are correctly locked down. This is possible to read regardless of whether any single row ever comes back.
Why AI-built Supabase apps hit this
The pg_graphql extension is enabled in Supabase projects, and the /graphql/v1 endpoint is part of the platform, not something your generated code chose to turn on. So the endpoint exists whether or not your app uses it. Many AI-built apps talk to Supabase only through the REST client or the JS SDK and never touch GraphQL, yet the GraphQL door is open the whole time.
That matters because of how the access boundary is usually reviewed. When a prompt produces "a table for orders" and a working UI, the table functions on the first try because the anon key is wired up correctly. The per-table decision about who may read each row is the step that does not happen automatically. RLS is opt-in per table in Postgres, so a table created without an explicit policy is served to the anon role.
This is an AI-generated-insecure-default pattern at the platform layer: the unsafe-by-omission state is the starting state, and the working app hides it.
- The same missing policy that exposes a table over REST exposes it over GraphQL. There is one authorization gap, reachable through two endpoints.
- A reviewer who only checks REST, or only checks the dashboard Table Editor, can miss that GraphQL is a parallel path to the same rows.
- Introspection is a separate default. It is convenient during development and is rarely turned off before launch, because nothing in the build breaks if it stays on.
Check both the schema and the data from the outside
You can confirm all of this against your own project without any attack. Find your project URL and anon key first; they ship in the client bundle, so open DevTools, the Network tab, and watch requests to your project at supabase.co. Do this only against your own project.
First, the data path. Send a GraphQL query to /graphql/v1 with the anon key in the apikey header, asking for a collection that maps to a table you believe should be private, for example the orders or profiles collection, selecting a few fields. Compare the result to the same table over REST at /rest/v1/.
Second, the schema path. Send a small introspection query, the standard __schema or __type request, to /graphql/v1 with the anon key.
- Suspicious result: the GraphQL query returns rows from a table that should require a logged-in user. That table is readable by anon, and the cause is missing or permissive RLS, not GraphQL itself.
- Safer result: the query returns an empty collection or a permission error for tables that should be private. An empty result with RLS on and no matching policy is the expected deny.
- Check this per table. RLS is per-table, so one protected collection tells you nothing about the next.
- If introspection answers with type names, your table and column names, and the relationships between them, an anonymous visitor can read the shape of your data model. That is the informational signal.
- This is true even when every table's rows are correctly locked down. Introspection describes structure, not contents.
Fix the data leak first, then decide about introspection
These are two fixes for two problems, and they are not equally urgent.
The data leak is the launch blocker, and the fix is RLS, not anything GraphQL-specific. Enable RLS on every table that holds user, account, payment, or admin data, then write explicit policies for the operations you actually want, for example scoping reads to the row owner by matching the authenticated user id against an owner column. Because GraphQL and REST share the same policies, closing the table over RLS closes it on both endpoints at once. There is no need to "secure GraphQL" separately; secure the table. For the policy patterns that compile but still leak, see the note on Supabase RLS policy pitfalls.
Schema disclosure is a hardening task, not a blocker on its own. If you do not use the GraphQL API at all, you can disable the pg_graphql extension or restrict access to the endpoint, which removes the door entirely. If you do use it, you can limit introspection access through your project configuration so anonymous callers cannot read the full schema. Treat this as reducing how much an attacker learns for free, not as the thing that protects your data.
RLS controls which rows a caller can read or change across REST and GraphQL alike. It does not hide your schema, and it does not validate the contents of a write. A caller allowed to insert their own row through GraphQL can still submit malformed values, so input validation stays a separate job. And turning introspection off does not protect a table whose RLS is missing; the rows are still reachable through a direct query.
Where VibeCodeGuard fits, and what it does not prove
VibeCodeGuard's Launch Check probes /graphql/v1 from the public surface with the anon key. If a query returns rows to the anonymous caller, that is a direct signal of missing or disabled RLS on that table, surfaced with severity and fix direction. If introspection answers, the scan reports the exposed schema as an informational signal, so you can decide whether to leave that door open. It checks what an outside visitor can actually reach through this endpoint, the same way the companion check works for the REST endpoint in how to check if your Supabase tables are publicly readable.
What it does not do is read your policy logic. The scan sees what comes back through GraphQL with the anon key; it does not confirm that the policies you wrote match your intent, and it cannot test authenticated GraphQL access where a logged-in user reaches another tenant's rows. That is authorization review and needs manual work against your data model.
Next step: send one introspection query and one data query to your own /graphql/v1 with the anon key, fix any table that returns rows by enabling RLS, then run a focused Launch Check against the public URL before you share it.
A clean GraphQL probe does not prove every table is correctly scoped. It checks the anonymous public path through one endpoint, not what an authenticated user can reach or whether your introspection setting matches your risk tolerance. For broader GraphQL introspection considerations in production, see the dedicated note. Treat returned anon data as a launch blocker, and treat a clean result as one signal among several.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.