Locking down Supabase: the database is the security boundary
Supabase’s security boundary is the database. The managed HTTP tier - Data API, Auth, Storage, Realtime, Edge Functions - is a stateless public veneer over Postgres: reachable by design, and defended at the row (RLS), the grant, and the token. It keeps a public endpoint on every plan, so a perimeter control has nothing to attach to. Every “make Supabase private” question - an IAP over the instance, an IP allowlist on the Data API, proxy-only access, private by default - is the same request against that fact, and resolves to one of three options: push the control into the database, lock the database socket, or own the API layer.
Everything marked measured was validated on 2026-08-28 on throwaway micro projects in ap-southeast-1, plus a self-hosted PostgREST (official image, v16.2) in Docker against the project’s session pooler, and Cloudflare Workers - an Access-gated proxy on the erfi.dev zone, and a local wrangler dev instance for the rate limiter. Each lever was applied through the Management API, then every HTTP surface re-probed with three credential classes (no key, anon key, service key). Follow-up runs on the same micro shape covered column grants, the Auth switch-on levers and Storage/Realtime reach on 2026-08-31 (S13-S15); the pre-request hook across a restart, FORCE ROW LEVEL SECURITY, the audit trail and bans, Auth enforcement, the x-forwarded-for trust boundary and the no-RLS shape where a backend uses service_role and nothing else on 2026-09-03 (S16-S21); and header-keyed checks on cf-connecting-ip, their volatility and advisor coverage of extension objects on 2026-09-14 (S22). On 2026-10-10 a project created through POST /v1/projects gave the default privileges, pg_graphql, the OpenAPI root and the lockdowns met by the postgres role (DD01-DD04), the hosted MCP server gave the confirmation matrix (K07), and S18 was ported to the unified logs endpoint; each is marked where it appears, n = 1 per probe. Claims about platform internals not re-tested are marked asserted and linked. The reproducible form is the supabase-lab iap-lockdown and security-lockdown experiments, under Reproducing.
TL;DR:
- The managed HTTP tier has no network perimeter. Network restrictions and PrivateLink act on the Postgres socket; nothing acts on the Data API, Auth, Storage or Realtime at the network layer, on any plan.12 An IP allowlist for the REST API does not exist, and the DB-layer workaround for it does not fire on hosted, neither after a config reload nor after a full project restart.
- With no perimeter to harden, there are three options:
- Push the control into the database - grants, RLS, and claims-keyed identity (third-party auth). The boundary is the row.
- Lock the database socket - PrivateLink plus “restrict all”, on the one surface that has a network perimeter.
- Own the API layer - your own PostgREST with the managed Data API off, behind an edge you control.
- The managed levers tighten per service; none of them is a gate. “Data API off” wedges PostgREST only;
verify_jwtchecks key-possession not identity; disabling legacy keys leaves the new publishable keys working; CORS and a custom domain gate nothing server-side.
Why there is no perimeter to harden
Section titled “Why there is no perimeter to harden”The socket has a network perimeter - network restrictions and PrivateLink act on it.12 The HTTP tier has none: it answers on a public endpoint, and each lever on it, measured below, closes one service while the rest keep answering. Hardening it means moving the control off the edge.
| Lever | What it closes | What stays open |
|---|---|---|
Empty exposed schema (“Data API off”), set with PATCH .../postgrest | PostgREST REST + GraphQL (503 PGRST002 on a table path; /rest/v1/ root with the anon key still answers 401, and answers per key as in the OpenAPI root and the GraphQL probe) | Auth, Storage, Realtime, Edge Functions3 |
db_schema drops graphql_public | GraphQL only (406 PGRST106) | REST |
max_rows = 1 | caps rows per response | a bulk-read brake, not authz |
Realtime private_only = true | public-channel joins (refused at join) | the WebSocket still upgrades; anon socket connects4 |
disable_signup = true | new signups | an existing user’s login |
| Leaked-password protection (HIBP) | a breached password at signup (422 weak_password, S19, 2026-09-03) | a password change via PUT /auth/v1/user still sets a breached one (S11, 2026-08-28) |
| Legacy keys disabled | the legacy anon/service pair | the new publishable key generation still reads |
| Bucket set private | the public object URL (400 NoSuchBucket) | a service-key signed URL still serves the object |
Edge Function verify_jwt = true | callers with no key (401) | any valid project JWT, incl. the anon key |
CORS and a custom domain gate nothing. CORS reflects an arbitrary Origin and a request with no Origin still returns data, so a non-browser client ignores it; a custom domain is a branding CNAME, and the origin <ref>.supabase.co answers identically before and after.5 The only surfaces reachable with no credential at all are a public storage object and a verify_jwt = false Edge Function.
The OpenAPI root and the GraphQL probe, per key
Section titled “The OpenAPI root and the GraphQL probe, per key”Two reads that look like “the surface is open” answer differently per key and per extension state. Both were measured on 2026-10-10 on one project created that day through POST /v1/projects (Pro, Postgres 17.11; data-api-defaults DD02 and DD04), one trial per probe.
GET /rest/v1/ returns the OpenAPI document of the exposed schema, and the anon key no longer reads it. The announcement gave 2026-03-11 for new projects and 2026-04-08 for existing ones, and the refusal text Access to schema is forbidden.6 Measured, the key sent as apikey alone or with Authorization gave the same statuses and bodies:
| Key | GET /rest/v1/ |
|---|---|
| none | 401 No API key found in request |
| anon (legacy JWT) | 401 Invalid API key, with a hint naming the service_role key |
| publishable | 401 Secret API key required, with a hint naming secret API keys |
service_role (legacy JWT) | 200, swagger 2.0 spec, 5 paths |
sb_secret_ key | 200, the same spec |
The anon status matches the announcement; its text was not seen in any body. As a control, a granted table answered 200 to the anon, publishable, service_role and sb_secret_ keys. GET /v1/projects/{ref}/database/openapi with a personal access token returned 200 and the same 5 paths; ?schema=graphql_public returned 200 with 2 paths, and a request with no token 401 No access token provided. The spec’s title is the public schema comment. Not measured: the 2026-03-11 and 2026-04-08 split, since the one project was created after both dates.
/graphql/v1 is the second read. pg_graphql is disabled by default on new projects, announced 2026-01-26,7 and pg_graphql 1.6.0 turns introspection off for projects created from 2026-06-29.8 On the same project (DD02):
| State | { __typename } | { __schema } or __type |
|---|---|---|
fresh, extension absent (pg_available_extensions offers 1.6.2) | HTTP 200, errors: pg_graphql extension is not enabled. | the same answer |
after create extension pg_graphql | {"__typename":"Query"} at the first poll | HTTP 200, Unknown field "__schema" on type Query (__type likewise) |
after comment on schema public is e'@graphql({"introspection": true})' | {"__typename":"Query"} | __schema returns queryType.name Query and __type returns data, at the first poll |
after the same comment with "introspection": false | not read | __schema refused again at the first poll |
The HTTP status is 200 in every state, so a GraphQL probe sends { __typename } and reads the body. The lab’s inventory probe sent { __schema } and read 200 in all three states; it was changed on 2026-10-10, so a graphql=200 line in an older iap-lockdown RUNLOG for a project created after 2026-06-29 means only that the endpoint answered HTTP 200. Not measured: the corrected probe inside iap-lockdown L01 and L02 themselves, and a project created before 2026-06-29 with pg_graphql on and then upgraded to 1.6.0.
The Auth levers, switched on
Section titled “The Auth levers, switched on”Auth also has levers a customer switches on. Each hardens Auth alone and gates no other service.
| Lever | What it does | Measured |
|---|---|---|
| Before-user-created hook9 | runs your code on the signup path before the row exists; a domain allowlist or a fraud check rejects the account at the door | present and off by default (S14). As a Postgres function (pg-functions://postgres/public/<fn>) rejecting @mailinator.com: the client received the hook’s own message (400, disposable email domains are not allowed, error_code unknown), 200 for another domain, active 5s after the PATCH (S19) |
| CAPTCHA (hCaptcha or Cloudflare Turnstile)10 | gates the signup, sign-in and password-reset entry points against scripted abuse | present and off by default, provider defaulting to hCaptcha (S14). With Turnstile’s documented always-fail test secret, signup and the password grant both returned 400 captcha_failed: captcha protection: request disallowed (no captcha_token found); the documented dummy token was refused under that secret and admitted under the always-pass secret (200) (S19)11 |
| Auth rate limits12 | token refresh, OTP, email and SMS sends, verify, and anonymous sign-ins each take a lower ceiling than the shared default | the seven rate_limit_* fields present, one driven down and back (S14). rate_limit_anonymous_users set to 3: 15 anonymous sign-ins returned 3 x 200 and then 12 x 429 (over_request_rate_limit: Request rate limit reached), the first 429 at request 4 (S19) |
password_min_length, MFA TOTP | password policy and second factor | set to 12, a 4-character password on the update path refused with 422 (S03, S11). The value 12 is a design choice; the 422 is the measurement |
password_hibp_enabled | leaked-password protection | off by default; did not fire on PUT /auth/v1/user (S11); at signup it did, 422 weak_password: Password is known to be weak and easy to guess, please choose a different one. for a breached password, 200 for a strong one, with mailer_autoconfirm on so no email was sent (S19, 2026-09-03) |
S14 read the switch-on fields on a micro project (2026-08-31) and S19 drove them to their refusals (2026-09-03). In S19 the rate-limit count was exact, with no burst above the configured value in that window. In the seconds before the hook was active, signups returned a 400 without the hook’s message (observed while writing the probe, not recorded in the artifact), so a verifier waits for the hook’s own text.
What each platform control covers
Section titled “What each platform control covers”The controls a security review asks about outside the HTTP tier each have a documented scope. The right-hand column is what stays reachable, readable or uncovered with the control on.
| Control | Covers | Does not cover |
|---|---|---|
| Network Restrictions | Postgres and the database pooler1 | the HTTPS APIs (the docs name PostgREST, Storage and Auth) and supabase-js. Edge Functions lose direct database access while restrictions are on, and the docs point them at supabase-js instead;1 measured as a CONNECT_TIMEOUT under Option 2 |
| SSL enforcement | connections to Postgres, Supavisor and PgBouncer13 | the HTTP APIs, which enforce SSL on every connection regardless.13 (Side effect: applying or updating it triggers a fast database reboot.13) |
| PrivateLink | Postgres and PgBouncer connections from an AWS VPC in the project’s region; Team and Enterprise; Beta14 | the Supabase API, Storage, Auth and Realtime, which stay on public endpoints;14 and the uptime SLA, which does not cover Beta products.15 Measured in AWS PrivateLink to Supabase |
| Read-only organization role (Team and Enterprise) | Dashboard access without writes; its SQL runs as supabase_read_only_user, a member of pg_read_all_data16 | secrets: the access-control table footnotes that the Read-only role “is able to access secrets”.16 Nor row visibility: pg_read_all_data alone does not bypass RLS,17 but the lab read BYPASSRLS on supabase_read_only_user (governed-starter-kit RUNLOG and ACCESS-CONTROL.md, 2026-10-07), so a Read-only member’s SQL reads every row. A Read-only member’s own session was not exercised |
MCP server read_only=true | writes: it executes SQL “as a read-only Postgres user”18 | row visibility, as far as the docs say: they name no role and do not mention RLS. The server sends read_only: true to the Management API’s /v1/projects/{ref}/database/query,19 and the API documents its sibling /database/query/read-only as running as supabase_read_only_user.20 If the flag lands on that role, a read-only MCP session reads every row as the Read-only role does. Which role the MCP path runs as was not measured. What was measured on 2026-10-10 (the hosted MCP server at the serverInfo version 0.13.0 recorded in the RUNLOG, K07): with read_only=true a DROP was not confirmed and not run, and Postgres answered 25006 cannot execute DROP TABLE in a read-only transaction. The write stop holds; the role and BYPASSRLS question stays open |
| Extension version pinning ignored; Realtime schema lockdown (changelog entries) | CREATE EXTENSION ... VERSION installs the default version with a warning;21 operations on the realtime schema fail with permission denied for schema realtime22 | Measured as postgres (is_superuser off) on one API-created project, 2026-10-10 (DD03). create extension citext version '1.5' and '9.9' both installed the default 1.6 with the warning, shown on a pooler connection and not in the query endpoint’s response. Every ALTER EXTENSION ... UPDATE form errored XX000 pgaudit stack is not empty, where the changelog says the update proceeds; whether that is the no-op path or every update on a pgaudit project was not separated. Of ten Realtime operations six were refused (42501, with must be owner of ... text for the ALTER and DROP forms) and four were allowed: INSERT, UPDATE and DELETE on realtime.schema_migrations and drop trigger tr_check_filters, where the changelog lists INSERT, DELETE and the trigger drop as refused. The project’s Realtime version was not read |
| Enterprise uptime SLA | 99.9% of each calendar month, per product, for generally available products15 | Beta and Alpha products, and the listed exclusions, among them third-party vendors and cloud providers, integration partners, force majeure and ISP outages, customer misconfiguration or resource limits, and account suspension.15 Below Enterprise there is no uptime SLA; the remedy and plan split are in what the SLA does and does not cover |
A Read-only seat should be treated as a whole-database read (BYPASSRLS read on supabase_read_only_user, 2026-10-07; no member session exercised), and a read-only MCP session as one until a probe shows otherwise. Network Restrictions, SSL enforcement and PrivateLink act on Postgres connections only, so the HTTP tier stays as the lever table under Why there is no perimeter to harden measured it.
MCP confirmations depend on the client
Section titled “MCP confirmations depend on the client”The hosted MCP server documents confirmations on four tools (create_project, create_branch, execute_sql, apply_migration) and takes a skip_elicitations parameter to remove the prompt per tool.18 K07 (2026-10-10, RUNLOG, one project per run, n = 1 per cell) sent the same statements from five client shapes. The server speaks a legacy session shape (initialize, protocol 2025-11-25) and the 2026-07-28 stateless shape, where there is no initialize and every request carries its capabilities in params._meta; a confirmation is an answer with resultType: "input_required" that the client repeats with inputResponses and a requestState.
| Client | Capability declared | drop, truncate, update and delete without where, apply_migration with DROP |
|---|---|---|
| L0, legacy | none | ran, no confirmation |
| L1, legacy | elicitation.form at initialize | ran, no confirmation |
| S0, 2026-07-28 | none | ran, no confirmation |
| SU, 2026-07-28 | URL elicitation only | ran, no confirmation |
| S1, 2026-07-28 | elicitation.form in params._meta | asked each time |
L0, L1, S0 and SU ran 20 of 20 (four statements and one migration each) with no confirmation and the table changed. S1 was asked for drop, truncate, update and delete without where, delete ... where true, alter table ... drop column, a do block running execute 'drop table ...' and a multi-statement select 1; drop table ...; it was not asked for update ... where true, an update with a where, insert, select, or a string literal containing DROP. Declining or cancelling ran nothing (4 of 4 execute_sql statements and the migration, status: declined or cancelled). A confirmation therefore exists only for a client that declares form elicitation in the 2026-07-28 metadata, and what it covers is decided by statement shape.
skip_elicitationsis a per-tool comma list. Withexecute_sql,apply_migrationan S1 client that would have declined was not asked and the statements ran; skippingapply_migrationalone left anexecute_sqlDROP asking.Execute_SQL,all, an unknown name and the parameter given twice each returned HTTP 400.- One acceptance covers repeats of that query. The
requestStatecarries keysb,expandp(tool, project and a query hash), withexp119 s ahead. Replayed with the same query it ran the statement again; presented with a different query (a DROP) it was refused. Behaviour pastexpwas not tested. - Branches: for L0, L1, S0 and SU a project-scoped
create_brancherroredCost confirmation ID does not match the expected cost of creating a branchand made no branch. On an account-scoped connection the helper toolsget_costandconfirm_costexist, and the script called them with no person between the calls.reset_branch,rebase_branchanddelete_branchraised no prompt under S1. - Claude Code 2.1.287 declares form and URL elicitation. In
claude -pwith an Elicitation hook that answered throughhookSpecificOutput.action,23 it declined or accepted (a statement applied or a branch created only on accept); with no hook-pcancelled and the DROP did not run. Withskip_elicitationson both SQL tools no hook event fired and the DROP applied. A hook that nested the answer underdecisionmade the model reportcancelledin all 6 SQL runs and both branch runs. The outcome was read from database state and the hook event count (10 runs, one model); the interactive dialog, other clients andcreate_projectwere not run.
read_only=true is the lever measured to stop the write (25006, above); a hook or script that answers for the user turns a confirmation into a pass-through.
Which option fits the goal
Section titled “Which option fits the goal”Before picking an option, read the Management API security advisor. In security-lockdown S01 it caught every seeded exposure - rls_disabled_in_public, rls_enabled_no_policy, security_definer_view, anon_/authenticated_security_definer_function_executable, function_search_path_mutable - so for the database half it answers “are we locked down?” before any lever is pulled, and again after each one.
| Goal | Option | Mechanism | Cost |
|---|---|---|---|
| Close data to anon, keep the managed tier | into the database | grants, RLS, claims-keyed identity | authz logic lives in Postgres |
| A private database path | lock the socket | PrivateLink + “restrict all” | Team/Enterprise; AWS VPC in-region2 |
| IP-restricted or rate-limited REST | own the API layer | your own PostgREST, Data API off | you run PostgREST |
| The whole HTTP tier private | own everything | self-host / BYOC | you run the stack |
Option 1: push the control into the database
Section titled “Option 1: push the control into the database”The boundary is the row, so the control belongs on the row, set through grants, RLS, and the token.
Grants
Section titled “Grants”The default is changing
Section titled “The default is changing”On existing projects a table created in public receives SELECT, INSERT, UPDATE and DELETE for anon, authenticated and service_role by default, and Supabase is changing the platform default to revoke those automatic grants.3 The rollout was announced on 2026-04-28:24
| Date | What changes |
|---|---|
| 2026-04-28 | opt-in at project creation: leave the “Automatically expose new tables” checkbox unchecked |
| 2026-05-30 | the new behaviour becomes the default for new projects |
| 2026-10-30 | enforced on existing projects: a new table in public without an explicit grant stops being reachable through the Data API |
The change is to default privileges, so it governs tables created afterwards. The announcement’s opt-in SQL changes the defaults for future tables and sequences the postgres role creates in public, and existing objects keep their current grants.24
A project the lab created with OpenTofu on 2026-08-28, after the new-project date, still had the old default. In iap-lockdown L08 a table created with no GRANT was readable by anon through the Data API. The first L08 run on that project made that observation before any lab statement had altered a default privilege. Its attempt to alter them was refused (42501), and its teardown then granted SELECT on new tables to anon and authenticated. Revoking postgres’s default privileges closed the next new table in the second run, after that teardown, so those runs did not read the default ACL itself (RUNLOG, L08). The cause was not established.
The default ACL was then read directly (measured 2026-10-10, DD01, RUNLOG). A project created that day through POST /v1/projects (Pro, Postgres 17.11) was read before any statement ran. In public, for both postgres and supabase_admin, anon, authenticated and service_role each held arwdDxtm on tables, rwU on sequences and X on functions (DD01a). A table made in SQL was then readable with no GRANT: 200 for the anon key after 2 s of schema-cache wait, and 200 for the publishable key (DD01b). The v1 create-project body, read from the published OpenAPI document that day, has no field for the setting, and the announcement describes it on the project creation screen only. So the old default was in force on a project created after the 2026-05-30 row above. Two readings fit: the new default does not reach projects created through the API, or the gradual rollout had not reached this project. One project cannot separate them. The 2026-10-30 row stays as announced; the standing-project re-run on or after that date has not been done, so what enforcement does to a project created before 2026-05-30 is not measured.
select defaclrole::regrole, defaclnamespace::regnamespace, defaclobjtype, defaclacl from pg_default_acl where defaclobjtype = 'r' shows what a given project will grant on new tables: an anon= entry for postgres in public means new tables arrive exposed. Whatever it shows, grant each table explicitly and enable RLS on it;3 a migration written that way behaves the same before and after 2026-10-30.
One table
Section titled “One table”For a table that must shut to anon without a policy migration, the grant is the lever. The next table a migration creates is readable again unless the default privileges move with it (iap-lockdown L08).
| Step | Effect through the Data API | Rests on |
|---|---|---|
REVOKE SELECT ON <table> FROM anon, authenticated | anon gets 42501; service_role keeps reading | L08 |
The next CREATE TABLE | pg_default_acl grants SELECT on the new table to anon and authenticated, so it is anon-readable again (service_role reads regardless) | L08 |
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT ON TABLES FROM anon, authenticated as the postgres grantor | the SELECT-only form: the next new table arrived closed, and the lab recorded 404 PGRST205 for it | L08 |
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM anon, authenticated, service_role, the four-privilege form of the announcement’s opt-in (the statement as the probe worded it; plus the sequences form), on the 2026-10-10 API-created project | a new SQL table answered 401 to anon and to the publishable key and 403 to service_role and to an sb_secret_ key, all with 42501 permission denied for table <t> and the hint Grant the required privileges to the current role with: GRANT SELECT ON public.<t> TO anon; (TO service_role for the two secret keys). The default ACL for the three roles became Dxtm, so TRUNCATE, REFERENCES, TRIGGER and MAINTAIN stayed. GRANT SELECT ... TO anon re-opened the table at once (200, no wait, anon ACL rDxtm), with service_role still 403 | DD01c, DD01d |
The same for the supabase_admin grantor | not alterable by the project owner (42501); platform-created objects keep their default anon grants | L08 |
The two revokes differ in the privileges named (SELECT against four), the roles (anon and authenticated against those two plus service_role), the project and the date (2026-08-28 against 2026-10-10), and the two closed tables answered differently: 404 PGRST205 and 401/403 42501. The runs do not say which difference accounts for it, and no cause is claimed here. A probe after a default-privilege change should read the body as well as the status. Filed upstream: no, for the 404-against-42501 difference.
The whole schema: the no-RLS shape
Section titled “The whole schema: the no-RLS shape”The shape a pen test most often finds is no RLS: authorisation in application code, the backend using service_role, and every table readable by any holder of the anon key. Grants alone close that (security-lockdown S21, 2026-09-03), but a revoke aimed only at anon and authenticated leaves the RPC surface open.
| Step | anon table | authenticated table | anon RPC | service_role |
|---|---|---|---|---|
1. REVOKE ALL ON ALL TABLES IN SCHEMA public FROM anon, authenticated, the same for sequences and functions, REVOKE USAGE ON SCHEMA public FROM anon, authenticated, and the matching ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public | 401 42501: permission denied for table | 403 42501: permission denied for table | 200 - still callable | table 200, 2 rows |
2. REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM public | - | - | 401 42501: permission denied for function | RPC 200 |
| A table and a function created after steps 1-2 | table 401 42501 | - | function 200 - callable again | 200 |
3. ALTER DEFAULT PRIVILEGES FOR ROLE postgres REVOKE EXECUTE ON FUNCTIONS FROM public (global, no IN SCHEMA), then another new function | - | - | 401 42501 | 200 |
- Functions carry EXECUTE for PUBLIC (
proacl {=X/postgres,...}) andpublickeeps USAGE for PUBLIC, so a revoke aimed atanonandauthenticatednever touches them. - Per-schema default privileges add to the global default and cannot remove PUBLIC’s built-in EXECUTE;25 only the global form does. The table created after the schema-level revoke stayed closed, because tables have no PUBLIC default.
The exposed schema
Section titled “The exposed schema”Moving the API surface to a dedicated exposed schema is the same lockdown at the PostgREST layer, and it is project-wide (S21). With db_schema set to api alone, service_role reading a public table got 404 PGRST205: Could not find the table 'api.sec21_pii' in the schema cache 3s after the PATCH, and read the api view with or without an Accept-Profile: api header (the first exposed schema is the default). The backend’s own calls move with the setting.
Where RLS still leaks
Section titled “Where RLS still leaks”Three RLS mistakes still fail a pen test after “we added RLS”:3
- A view over an RLS table leaks every row unless it is created
with (security_invoker = true). In iap-lockdown L08 the plain view returned every row to anon and the invoker-security view 0. - An UPDATE policy gates which rows change, never which columns. A
WITH CHECKconstrains the row’s new values, not which of its columns a caller may touch. - PERMISSIVE policies OR together, so an admin-intended policy bleeds onto anon.
Column writes are closed by a grant
Section titled “Column writes are closed by a grant”REVOKE UPDATE ON <table> FROM anon, authenticated followed by GRANT UPDATE (<the writable columns>) makes a write to any withheld column return 42501 (permission denied for column) while the row policy still decides which rows move.26 Security-lockdown S13 (2026-08-31) measured the pair:
| Setup | anon write to balance | anon write to the granted column |
|---|---|---|
| Permissive UPDATE policy + table-level UPDATE grant | 204 - the hole a pen test writes through | 204 |
Same policy, REVOKE UPDATE + GRANT UPDATE (note) | 401 carrying SQLSTATE 42501 | 204 |
FORCE ROW LEVEL SECURITY binds only a non-BYPASSRLS owner
Section titled “FORCE ROW LEVEL SECURITY binds only a non-BYPASSRLS owner”A team that wants RLS applied to its backend’s roles reaches for FORCE next. It applies RLS to the table owner, and only if the owner cannot bypass RLS (security-lockdown S17). On a managed project the owner usually can:
| Role | BYPASSRLS | Note |
|---|---|---|
postgres | yes | the default owner; the role the Management query endpoint runs as (S17a; the Dashboard SQL editor is documented to use the same role) |
service_role | yes | the backend key |
supabase_admin | yes | platform |
authenticator, anon, authenticated, supabase_auth_admin | no |
| Reader, RLS on and no policy | Rows before FORCE | Rows after FORCE |
|---|---|---|
postgres on a postgres-owned table | 2 | 2 |
a lab role without BYPASSRLS on a table it owns | 2 | 0 |
service_role through REST | 200, 2 rows | 200, 2 rows |
ALTER ROLE service_role NOBYPASSRLS as the project owner returned 42501: "service_role" is a reserved role, only superusers can modify it. Role choice does apply RLS to a backend:
- Create a role
NOBYPASSRLSand grant it toauthenticator. - Mint the backend’s HS256 JWT with that role in the
roleclaim, signed with the secretGET /projects/{ref}/postgrestreturns. - Call the Data API with it:
200with 0 rows under no policy,200with 1 row once atenant = 'a'policy existed.
Identity: the token as the key
Section titled “Identity: the token as the key”Register an external issuer as third-party auth (the jwks_url shape resolves; an inline key set never does) and key RLS on a claim only the issuer sets.27 Measured with Cloudflare Access-for-SaaS registered as the issuer (its JWKS resolved on registration) and the admit/deny run with a lab ES256 issuer whose JWKS was served from an Edge Function, since a real Access token needs the interactive login:
Caller, policy (auth.jwt() ->> 'iss') = '<issuer>' | Rows read |
|---|---|
| anon key | 0 |
| GoTrue user token | 0 |
| token minted by the trusted issuer | its rows |
That is “an IAP over the data” done at the authorisation layer, and it is plan-agnostic. The step-by-step is Put an IAP over the Supabase Data API; keeping RLS while dropping the Supabase HTTP layer entirely is RLS without Supabase Auth.
Where this option stops
Section titled “Where this option stops”Four of the five limits below are measured; the pg_headerkit one rests on its published source plus the two measured mistakes it packages.
The IP filter that does not fire on hosted
Section titled “The IP filter that does not fire on hosted”Key the check on cf-connecting-ip, inside the object
Section titled “Key the check on cf-connecting-ip, inside the object”The hook is a connection-level gate and hosted never activates it, so the check has to sit in the object itself: a policy, a view, or an RPC. That form was measured on 2026-09-14 (S22b) with two tables whose policies differ only in the header they read, an allowlist holding one address, and a single request carrying that address in x-forwarded-for:
| Policy keyed on | Rows returned to the forged request |
|---|---|
cf-connecting-ip | 0 |
split_part(x-forwarded-for, ',', 1) | 1 |
Both functions were stable for that comparison, so the only variable was the header. Written out, the form that held:
create function app.caller_ip() returns text language sql stable -- never immutable; see below set search_path = '' as $fn$ select coalesce(current_setting('request.headers', true)::json ->> 'cf-connecting-ip', '') $fn$;
create policy allowed_ip on app.t for select to anon using (exists (select 1 from app.allow_list a where a.ip = app.caller_ip()));Volatility is a planner property, so it decides whether the check runs per request at all, whichever header you read. Marking that function immutable gives it constant arguments, and a zero-argument constant expression is evaluated when the statement is planned rather than when it runs (S22c, same body, same qual):
IMMUTABLE STABLEResult Result One-Time Filter: false One-Time Filter: (COALESCE(((current_setting( 'request.headers', true))::json ->> 'cf-connecting-ip'), '') <> '') -> Seq Scan on public.tThe immutable plan has no current_setting call left in it and no scan: the planner read the header once, folded the answer to a constant, and the policy stopped being a per-request check. Postgres inlines both SQL functions, so neither plan names the function; read the plan for a surviving current_setting call.
pg_headerkit reads the forgeable element
Section titled “pg_headerkit reads the forgeable element”Both mistakes are packaged together in pg_headerkit, a set of hdr.* helpers over request.headers reachable from the dbdev registry’s launch post as an IP allow/denylist recipe for PostgREST. Its hdr.ip() is split_part(hdr.header('x-forwarded-for') || ',', ',', 1), the form that returned 1 row above. Three of its 24 functions are stable (hdr.headers(), hdr.header(), hdr.ip()); the other 21 are immutable, and that set includes the two you would put in a policy, hdr.in_allow_list() and hdr.in_deny_list(), which take no arguments and call the stable hdr.ip(). Postgres does not check a function’s declared volatility. It is v1.0.0 on dbdev, last substantive commit 2023-03-03, and predates the edge measured here. Its README lists rate limiting, request logging, and user allow/denylists as unchecked roadmap boxes rather than shipped functions; the published SQL is header accessors, two inet tables, membership checks, and user-agent predicates.
Storage and Realtime are not on the REST path
Section titled “Storage and Realtime are not on the REST path”Every control above sits on the PostgREST path, and two managed services never traverse it (security-lockdown S15, 2026-08-31):
- Storage and Realtime keep answering with the Data API off. With the schema wedged empty (
503 PGRST002on a table path),/storage/v1still served (200) and/realtime/v1still answered, because each is its own service against the same Postgres. An IP allowlist or an owner-list read from request headers gates the REST layer and nothing else; for Storage and Realtime the same intent lives in each service’s own authorisation - RLS onstorage.objects, Realtime Authorization - or on an edge you own. - The public-schema REVOKE does not reach
storage. The schema is owned bysupabase_admin, not the project owner (measured), so its tables keep their own grants and Storage stays governed by RLS onstorage.objects. - Claims read straight from the wire depend on the connection credential. The claims GUC is unprivileged, so the connection credential becomes the boundary; RLS without Supabase Auth works it through with a tampered-claim test.
Egress from inside Postgres
Section titled “Egress from inside Postgres”net.http_get('https://...') returned 200 from SQL on a fresh project (security-lockdown S08): a SQL-capable caller can exfiltrate or SSRF from inside Postgres. Restrict EXECUTE on schema net to the roles that need it, or leave pg_net disabled if nothing uses it.
Option 2: lock the database socket
Section titled “Option 2: lock the database socket”The socket is the one surface with a network perimeter. PrivateLink puts the connection on AWS VPC Lattice, and DB network restrictions narrowed to a break-glass block close the public direct and pooler paths while the endpoint keeps serving - the full public-DB lockout, self-service, on Team or Enterprise. The behaviour, the DNS/TLS pattern, and the latency numbers are in AWS PrivateLink to Supabase.2 This option does nothing for the HTTP tier: PrivateLink covers Postgres and the pooler only, and the Data API stays public by design.
Through the shared Supavisor pooler, a caller outside the restriction list is refused at once with FATAL (EADDRNOTALLOWED) address not in tenant (security-lockdown S10) - the verification text for the socket lock. On the public direct 5432 and dedicated PgBouncer 6543 paths the excluded address is dropped rather than refused, so the client sees its own connect timeout (timeout expired at 6.7 s against 2.7-3.3 s for the Supavisor refusal; recovery 3.8-4.3 s on all four paths after the list is reopened; medium-serverless MS03, 2026-09-30). And Supabase-hosted compute is outside the list too: under restrict-all an Edge Function’s direct pg connect over SUPABASE_DB_URL fails with CONNECT_TIMEOUT db.<ref>.supabase.co:5432 while the same function’s /rest call with the service key still serves (iap-lockdown L22), which pushes that data onto the public HTTP tier this option was meant to keep it off. Inventory Edge Functions that open direct connections before applying the restriction; under restrict-all they move to the Data API path or break (L22 measured one function). Whether pg_cron jobs or database webhooks are affected was not measured.
Option 3: own the API layer
Section titled “Option 3: own the API layer”When the goal is an IP-restricted, rate-limited, or otherwise network-controlled REST layer, the managed tier has no lever for it, so take the API layer in-house: turn the Data API off and run PostgREST yourself against the same Postgres, behind an edge you control. Turn it off with PATCH /v1/projects/{ref}/postgrest and db_schema: "", never the dashboard toggle. The toggle’s off-then-on round-trip rewrites db_schema to the constant public and drops graphql_public with any extra schema, while the API round-trips what you give it (http-tier-lockdown run 2).
S04 measured this end to end: managed REST dark (503 PGRST002), a PostgREST v16.2 container connected to the project’s session pooler serving the same rows, and its db-pre-request filter rejecting a spoofed x-forwarded-for with 403 while an allowed request served. The filter judges whatever header reaches it, so the deployment decides what that header holds (S20, 2026-09-03). Called direct on the container with x-forwarded-for: 198.51.100.7, an RPC returning the header saw that value (200, 1 address); called through an nginx edge that sets X-Forwarded-For $remote_addr, it saw one RFC 1918 address and no client value; and the S04 ban on 203.0.113.9 returned 403 PT403 direct and 200 through the edge. The filter is an allowlist only when PostgREST is reachable from nowhere but the edge. The step-by-step build is Run your own PostgREST against a Supabase project.
The proxy-only variant of this option - a Worker in front of the managed origin - can be bypassed until the browser-usable keys are revoked. A Worker holding the service key does not gate the origin, because <ref>.supabase.co keeps answering anyone with a key; direct origin with the anon key returned 200 with the proxy in place. It becomes the only path once the browser-usable keys are revoked (direct origin then 401 Legacy API keys are disabled). Once the keys are revoked, supabase-js from the browser is gone and the Worker owns authorisation because service_role bypasses RLS - which is a backend in front of a database, the same shape as running your own PostgREST.
Revocation is three separate facts in iap-lockdown L05 and L11: disabling the legacy keys refuses the legacy anon JWT in about 45s (48s on the L11 run), the newer sb_publishable_ generation is independent and keeps reading until it is revoked as well, and the control plane re-mints a key at will (201), so “revoked” is a posture any PAT holder can undo.
Rate limiting runs on the edge either way: nginx limit_req (2 requests/second, burst 2) in front of the container returned 2 served and 13 rejected on a 15-request burst, and a Cloudflare Worker with the Rate Limiting binding (run locally via wrangler dev) returned the same 2 and 13; a WAF rule or Upstash does the same job (S05 for the nginx limiter, S12 for the Worker variant).30 It counts only the traffic that passes through it, so it limits a closed origin and cannot limit the managed endpoint, which answers any key-holder directly.
The secrets this option creates - the service key the Worker holds, the connection role’s password - can live in the database: supabase_vault stores them as ciphertext in vault.secrets and returns them through vault.decrypted_secrets (security-lockdown S07), rather than a plaintext column or an environment variable in a compose file.
The evidence side: who accessed what, and blocking them
Section titled “The evidence side: who accessed what, and blocking them”The trail a customer credential reaches is the logs endpoint; there is no drain path in /v1 and the Auth audit endpoint returned 0 entries (security-lockdown S18, 2026-09-03). Blocking exists for the database socket only.
The audit trail
Section titled “The audit trail”| Event | Where it appears | Matched on | Lag |
|---|---|---|---|
anon GET /rest/v1/sec18_<nonce> (404) | edge_logs, with a client-address field (cf_connecting_ip or x_real_ip) populated in the request headers | metadata.request.path | 18s |
service-key GET /storage/v1/bucket?sec18=<nonce> (200) | edge_logs, same fields | metadata.request.url | 15s |
failed password grant (400 invalid_credentials) | auth_logs, as a request line; the row read back carried method, path, status, error_code and remote_addr, no email | path and error_code | 2s |
| successful login (200) | auth_logs, as an auth_event line; the email was in actor_username in the row read back | action: login plus the email | 3s |
| either login | GET /auth/v1/admin/audit with the service key | - | returned 200 with 0 entries throughout |
The /v1 OpenAPI spec has 115 paths and none mentions a drain, so Log Drains are configured in the Dashboard only.31
The table above is the 2026-09-03 reading, taken through /analytics/endpoints/logs.all: the lab queried it because the sibling /analytics/endpoints/logs answered Backend error! Retry your query. to the same SQL that day. That is history. The removal of logs.all was announced on 2026-07-23 for 2026-09-23,32 and on 2026-10-10 it answered 410 with The logs.all endpoint has been removed. Use GET /v1/projects/{ref}/analytics/endpoints/logs instead. (measured, edge-resilience W27b). The published OpenAPI document fetched that day still listed the path; that is a hand read and the copy was not kept. Filed upstream: no, for the listing.
S18 was ported to /analytics/endpoints/logs on 2026-10-10 (ClickHouse dialect: where source = 'edge_logs', nested fields as log_attributes['request.path'] and log_attributes['request.url'], and a key a row lacks reads as the empty string; the changelog says to filter by source_name, and source is what the runs used and passed with, source_name not tried). Its second run passed 5 of 5 on one fresh Pro project: the marked REST request was found in edge_logs 13 s after it was sent, with cf_connecting_ip present, the marked Storage request 13 s after, the failed password login in auth_logs 1 s after the query started and the successful login’s auth_event 4 s later; /auth/v1/admin/audit returned 200 with 0 entries; 10 failed pooler authentications produced 1 ban and DELETE /network-bans returned it to 0. In the port’s first run S18b did not find its row in 264 s, and the cause is not established (one run, no repeat); a row for that request was read about ten minutes later by hand, unrecorded. The query conversion is in Query Supabase project logs through the Management API after logs.all.
What the database logs by default
Section titled “What the database logs by default”The platform’s logging defaults on a fresh project, read with show on
2026-09-08 (audit-integrity A01d, a Pro project and a Team project) and again
on 2026-09-30 (medium-serverless MS01a, a nano project in another
region):3334
| Setting | Fresh-project value | What it leaves unlogged |
|---|---|---|
log_statement | ddl | data changes - a DELETE and a TRUNCATE of an audit table are not statement classes, and neither appeared in postgres_logs in a 191 s search, while the DDL control appeared in 191 s (A03a-A03c) |
log_min_duration_statement | -1 | every duration-based read - disabled |
log_connections, log_disconnections | off | connection lifecycle events |
pgaudit | not installed, pgaudit.log = none, pgaudit 17.1 available to install | statement-level data-change logging, which is an extension here rather than a configuration toggle |
log_min_error_statement | error | nothing at error level, which is the one error-visibility default that is on |
The connection lifecycle is the part the platform lets you raise: a
PUT /config/database/postgres carrying log_connections answers 200 and
takes, while the same call carrying log_statement answers
400 Unrecognized key: "log_statement" (A04a, A04d). The audit trail
reference carries that half.
So a lockdown’s evidence side cannot rest on Postgres logs alone: a control
that logs DDL proves a table was created and says nothing about what was read
out of it. The row-level story has to come from edge_logs and auth_logs
(the table above) or from pgaudit.
Blocking a bad actor
Section titled “Blocking a bad actor”The three ban paths in the spec are network-bans/retrieve, network-bans/retrieve/enriched and DELETE network-bans, and all of them act on the database socket.35
| Step | Result |
|---|---|
POST network-bans/retrieve before | 0 banned |
10 failed psql authentications through the session pooler (FATAL: password authentication failed) | 1 address banned (the lab machine’s, by the fact that psql from it succeeded again only after the DELETE) |
DELETE /network-bans with that address | 200; 0 banned after |
| correct-password psql through the pooler afterwards | succeeded |
Nothing in the spec bans an address at the HTTP tier; for Data API, Auth and Storage traffic that control lives in the row, the token, or an edge you own.
Which option to pick
Section titled “Which option to pick”The same decision, as text. Start from what has to be private:
- Just the database socket? Yes: Option 2, PrivateLink + restrict-all. No: go to 2.
- Identity on the Data API, keeping the managed tier? Yes: Option 1, third-party auth + claim-keyed RLS. No: go to 3.
- IP or rate-limit the REST layer? Yes: Option 3, your own PostgREST with the Data API off. If the whole HTTP tier has to be private: self-host or BYOC (the whole stack private).
The identity half of every option above - which key signs, who verifies it and how strictly, what a third-party consumer caches, and what running GoTrue yourself against the managed database does and does not give you - is measured in one place in Supabase Auth end to end.
What to do about it
Section titled “What to do about it”Each row names the module id it rests on (security-lockdown unless prefixed); a row that is a design choice rather than a result says so.
| Practice | Evidence | Module |
|---|---|---|
| Read the Management API security advisor before choosing an option and after every change. | It caught every seeded exposure (rls_disabled_in_public, rls_enabled_no_policy, security_definer_view, anon_/authenticated_security_definer_function_executable, function_search_path_mutable). | S01 |
Restrict EXECUTE on schema net to the roles that need it, or leave pg_net disabled. | net.http_get('https://...') returned 200 from SQL. | S08 |
| Inventory Edge Functions that open a direct database connection before Option 2. | Under restrict-all the pg connect over SUPABASE_DB_URL fails with CONNECT_TIMEOUT db.<ref>.supabase.co:5432 while the /rest fallback with the service key still serves, so they move to the Data API path or break. Whether pg_cron jobs or database webhooks are affected was not measured. | iap-lockdown L22 |
| Verify the socket lock through the pooler from an excluded address. | Expect FATAL (EADDRNOTALLOWED) address not in tenant on the shared pooler; on direct 5432 and the dedicated pooler expect a connect timeout instead (medium-serverless MS03). | S10 |
| Grant each new table explicitly and enable RLS on it, whatever the platform default. | The documented default on existing projects gives new public tables all four privileges for anon, authenticated and service_role; from 2026-10-30 new tables get none, and existing tables keep their grants; a project created with OpenTofu on 2026-08-28 still exposed a table created with no GRANT to anon. A project created through POST /v1/projects on 2026-10-10 showed arwdDxtm for the three roles in pg_default_acl before any statement ran, and its SQL table was anon-readable; the re-run on a standing project after 2026-10-30 is not done. | iap-lockdown L08, data-api-defaults DD01 |
| Probe a new table after changing default privileges, and read the body. | After the SELECT-only default revoke a new table answered 404 PGRST205 (L08). After the four-privilege revoke on anon, authenticated and service_role it answered 401 (anon, publishable) or 403 (service_role, sb_secret_) with 42501 and a GRANT SELECT ON public.<t> TO anon; hint (DD01c). Different statements, projects and dates; no cause claimed. | iap-lockdown L08, data-api-defaults DD01c |
| Do not give a Read-only seat or read-only MCP to anyone barred from some rows. | supabase_read_only_user carries BYPASSRLS (read 2026-10-07) and the Read-only role can access secrets per the access-control docs; which role the MCP read-only path uses was not measured. | governed-starter-kit RUNLOG, a RUNLOG record with no module |
| Do not rely on an MCP confirmation to stop a destructive statement. | Raw clients with no capability, a 2025-initialize form capability, or URL elicitation only ran DROP, TRUNCATE, UPDATE and DELETE without WHERE and apply_migration with DROP with no confirmation (20 of 20); only a request declaring elicitation.form in params._meta of the 2026-07-28 shape was asked. skip_elicitations is per tool and removes the prompt; one accepted requestState replayed ran the query again. read_only=true stopped DROP in Postgres (25006), role question open. | K07 |
In Claude Code automation, answer the Elicitation hook with hookSpecificOutput.action. | With the answer there, a hook declined or accepted (statement applied or branch created only on accept); claude -p with no hook cancelled; with the answer nested under decision all 6 SQL runs and both branch runs reported cancelled. 10 runs, one model, outcome read from database state. | governed-starter-kit RUNLOG, K07 hook runs |
Create views over RLS tables with (security_invoker = true). | A plain view returned every row to anon, the invoker-security view 0. | iap-lockdown L08 |
Set password_min_length to 12 or more and keep MFA TOTP verify on. | Both are set through PATCH /v1/projects/{ref}/config/auth. A 4-character password was refused with 422 on the update path; password_hibp_enabled is off by default and, once on, does not fire on PUT /auth/v1/user. The value 12 is a design choice; the 422 on a 4-character password is the measurement. | S03, S11 |
| Treat key revocation in the proxy-only variant as three separate acts. | Disabling legacy keys refuses the legacy anon JWT after about 45s, the sb_publishable_ generation is independent and keeps reading, and the control plane re-mints a key at will (201), so any PAT holder reopens the path. | iap-lockdown L05, L11 |
Turn the Data API off with PATCH /v1/projects/{ref}/postgrest, never the dashboard toggle. | Send db_schema: "". The toggle’s off-then-on round-trip rewrites db_schema to the constant public and drops graphql_public with any extra schema; the API round-trips what you give it. | http-tier-lockdown run 2 |
After turning the Data API back on, poll a table read for 200 before sending traffic. | The enable restarts PostgREST; in each outage both data paths answered 200 within 2870 ms of the PATCH (slower path 2870, 1320 and 952 ms after 30s, 300s and 900s off). /rest-admin/v1/ready (service_role key) answers 503 off and 200 on, but turned 200 930 ms before an RPC in one cycle. | data-api-reenable DA02 |
| Verify “REST off” on a table path. | 503 PGRST002 appears on data paths, while /rest/v1/ root with the anon key answers 401 in both states (with the service_role key it also turns 503 PGRST002, data-api-reenable DA02). | S15 |
Keep the service key and connection-role password a lockdown creates in supabase_vault. | supabase_vault stores ciphertext in vault.secrets and returns it through vault.decrypted_secrets. | S07 |
Use pgaudit for the evidence side and check GET /database/backups. | pgaudit is statement auditing to the Postgres log, read through the Management API logs. A fresh project reports pitr_enabled=false, walg_enabled=true, so there is no point-in-time window to restore from. | S09 |
For the no-RLS shape, revoke from PUBLIC as well as from anon and authenticated. | REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM public, and the global ALTER DEFAULT PRIVILEGES FOR ROLE postgres REVOKE EXECUTE ON FUNCTIONS FROM public (no IN SCHEMA). The anon-only revoke left every RPC at 200; the per-schema default-privilege revoke let a new function reopen. | S21 |
| Move the backend’s objects, or its calls, before flipping the exposed schema. | The exposed-schema change is project-wide: with db_schema set to api alone, service_role reading a public table got 404 PGRST205. | S21 |
| Apply RLS to a backend by role choice. | Create a NOBYPASSRLS role, grant it to authenticator, mint the backend’s JWT with that role claim. postgres, service_role and supabase_admin are BYPASSRLS, FORCE left a postgres-owned table fully readable, and ALTER ROLE service_role NOBYPASSRLS returns 42501 (reserved role). | S17 |
Raise log_statement or install pgaudit before relying on Postgres logs for a data change. | A fresh project reads log_statement = ddl with no pgaudit installed, so a DELETE and a TRUNCATE of an audit table were both absent from postgres_logs after a 191 s search while the DDL control appeared in 191 s. | audit-integrity A01d, A03b (2026-09-08) |
Read the client address from cf-connecting-ip in any header-keyed check. | On the managed tier a client-supplied cf-connecting-ip is refused at the edge with 403; the first x-forwarded-for element is the caller’s own. | S16, S22a |
Declare any function that reads request.headers stable. | immutable on a zero-argument one is folded at plan time - the qual becomes a constant and the check stops running per request. | S22c |
Do not build an IP allowlist on pg_headerkit. | Its hdr.ip() reads the attacker-controlled x-forwarded-for element, so a caller who sets that header selects their own address: the same request that a cf-connecting-ip policy denied was admitted by an hdr.ip() policy. Its membership checks are immutable besides. | S22b, S22c |
| Do not expect the security advisor to lint what an installed extension brought in. | A pg_tle extension carrying unpinned-search_path functions and an RLS-less table raised only extension_in_public, while plain-SQL objects of the same two shapes were flagged by function_search_path_mutable and rls_disabled_in_public in the same response. | S22d |
Do not plan around pgrst.db_pre_request on hosted. | The role GUC persists but no pre-request ran after NOTIFY (61s) or after POST /restart (182s after REST returned; the restart took REST down for about 302s, 303s from the POST to recovery). | S16, iap-lockdown L09 |
| Prove the CAPTCHA gate before relying on it, with Turnstile’s documented test secrets. | Always-fail 2x0000000000000000000000000000000AA, always-pass 1x0000000000000000000000000000000AA, dummy token XXXX.DUMMY.TOKEN.XXXX; with CAPTCHA on, signup and the password grant both return 400 captcha_failed without a token. | S19 |
| Put a domain allowlist in a before-user-created hook as a Postgres function. | pg-functions://postgres/<schema>/<fn> returning {"error":{"http_code":400,"message":...}}; the client receives that message. Wait for the hook’s own text when verifying: in the seconds before it was active, signups returned a 400 without that message (observed while writing the probe, not in the artifact). | S19 |
Lower the rate_limit_* fields to the count you mean. | On rate_limit_anonymous_users a value of 3 gave 3 x 200 then 429, no burst above it. | S19 |
Query edge_logs and auth_logs through /analytics/endpoints/logs. | logs.all answers 410 since 2026-09-23. On logs, where source = 'edge_logs' rows carry log_attributes['request.path'] and log_attributes['request.headers.cf_connecting_ip'] or x_real_ip (2026-10-10; the 2026-09-03 form on logs.all was metadata.request.path and metadata.request.headers.cf_connecting_ip); auth_logs carries auth_event.actor_username for logins and request lines with error_code for failures. /auth/v1/admin/audit returned 0 entries, and Log Drains have no /v1 path. | S18 (2026-09-03), S18 port (2026-10-10) |
| Expect a ban only at the DB socket. | Failed pooler authentications add the caller to network-bans, DELETE /network-bans lifts it; no ban lever exists for the HTTP tier. | S18 |
| On your own PostgREST, make the edge the only route to the container. | Have the edge set X-Forwarded-For $remote_addr: direct to the container the client’s header is what the filter sees (403 on a spoofed ban value), through the edge the edge’s peer address is (200). | S20, S04 |
Reading the numbers
Section titled “Reading the numbers”The split between the socket and the HTTP tier holds regardless of plan or region: the socket is lockable, the HTTP tier keeps a public endpoint, and a different tier changes neither which surface a lever touches nor that fact. Every “restrict the REST API by IP” request lands in option 3: it is not a managed capability and the DB-layer workaround does not fire on hosted, so the REST layer has to be one you own. The boundary generalises further than the status codes: controls that sit on the row, the grant, or the token travel with the database; controls that assume a network edge have to be rebuilt on an edge you run. The control plane is the same shape one layer up - api.supabase.com is public and PAT-only, so machine and ops access is gated at your own tooling behind an Access service token (the tooling holds the PAT), the pattern in kubectl behind Cloudflare Access. For the evidence side of a lockdown, pgaudit is available on the project (statement auditing to the Postgres log, read through the Management API logs), and GET /database/backups on a fresh project reports pitr_enabled=false with walg_enabled=true - recovery is daily backups until PITR is enabled (security-lockdown S09).
Reproducing
Section titled “Reproducing”Every measured claim comes out of four disposable supabase-lab experiments, plus one network-restriction module (MS03) from a fifth, medium-serverless. iap-lockdown provisions one micro project and probes the full HTTP surface under each lever, the two IAP patterns (Cloudflare Access-for-SaaS as the real issuer, plus a lab ES256 issuer whose JWKS is served by an Edge Function), and the custom-domain and CORS non-gates. security-lockdown adds the platform security advisor, network restrictions applied (with a psql socket-lock probe), the self-hosted-PostgREST path with a Docker PostgREST against the project pooler and an nginx rate limiter, the connection-role (S06), Vault, pg_net-egress and pgaudit checks, the follow-up probes - column grants (S13), the Auth switch-on levers (S14), and Storage/Realtime reach with the Data API off (S15) - and the 2026-09-03 modules S16-S21 (pre-request across a restart and the header shape, FORCE RLS, audit and bans, Auth enforcement, the x-forwarded-for trust boundary, the no-RLS shape), whose redacted artifacts and facts tables are under out/2026-09-03/. S22 (2026-09-14) adds the header-keyed checks - cf-connecting-ip trust, the policy form against the hdr.ip() form, the volatility fold, and advisor coverage of extension-owned objects - under out/2026-09-14/. The third is http-tier-lockdown, cited above for the Data API off-switch: it is OpenTofu plus a RUNLOG rather than a test battery, so its findings are cited by run rather than by module id. The logging defaults come from audit-integrity (A01d, A03a-A03c, 2026-09-08) and are re-read on a different project class by medium-serverless (MS01a, 2026-09-30). All four provision, probe, and destroy in one run; region and plan are variables. What turning the Data API back on costs, and what to poll, comes from the fourth, data-api-reenable, with its 2026-09-24 artifacts under out/2026-09-24. Claims dated 2026-10-10 come from two further runs. data-api-defaults provisions a Pro project through POST /v1/projects per run (two runs, 11 of 11 passing on the second; the first had two probe bugs, fixed) and covers the default privileges (DD01), pg_graphql (DD02), the extension-version and Realtime-schema lockdowns (DD03) and the OpenAPI root per key (DD04). The K07 module of governed-starter-kit measures the MCP confirmations. The S18 port and the W27 re-run on the unified logs endpoint are in the edge-resilience RUNLOG. No published out/ artifact is linked for these runs.
| Claim | Status | How it was checked |
|---|---|---|
| Data API off = PostgREST 503, other services up | tested | empty db_schema via PATCH /postgrest, full-surface re-probe |
| Data API off is a nonexistent exposed schema; turning it on restarts PostgREST | tested | PostgREST log after db_schema: "": Failed to load the schema cache using db-schemas=pg_pgrst_no_exposed_schemas, retries backing off to 32s; both enables in the log extract logged a fresh Starting PostgREST 14.5..., and in each outage both data paths answered 200 within 2870 ms of the enable PATCH, with no growth over 30s, 300s and 900s outages (DA02) or with 3002 tables (DA04) |
The Dashboard toggle re-enables with public alone | tested | Studio’s enable write replayed through the API on public,graphql_public,da_api: extra schema and /graphql/v1 406 PGRST106 about 71s after the enable (DA03); the click’s own recovery time not measured |
| Network restrictions gate the socket, not the HTTP tier | tested | restrictive CIDR applied, REST unchanged (S02; network restrictions) |
verify_jwt passes any project JWT | tested | anon-key call to a verify_jwt=true function returns 200 |
| Legacy-key disable leaves publishable keys working | tested | PUT /api-keys/legacy?enabled=false, publishable key still reads |
| Grants close a table; default privileges reopen new ones | tested | REVOKE + CREATE TABLE + ALTER DEFAULT PRIVILEGES, probed via /rest/v1 |
| An OpenTofu-created project on 2026-08-28 still granted anon on new tables, after the 2026-05-30 new-project default | tested; cause not established | a table created with no GRANT read by anon in the project’s first L08 run, before any lab statement altered a default privilege (L08); dates from the announcement |
A project created through POST /v1/projects on 2026-10-10 carried the legacy default privileges | tested; creation path against gradual rollout not separated | pg_default_acl read before any statement: arwdDxtm on tables for anon, authenticated, service_role, for postgres and supabase_admin in public; a SQL table read 200 by anon and by the publishable key (DD01a, DD01b); the standing-project re-run after 2026-10-30 is not done |
The four-privilege default revoke leaves a new table answering 42501, not 404 | tested, one project | after REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES for the three roles: 401 anon and publishable, 403 service_role and sb_secret_, ACL Dxtm; GRANT SELECT ... TO anon re-opened it at once (DD01c, DD01d); the SELECT-only form gave 404 PGRST205 in L08 |
GET /rest/v1/ is refused for anon and publishable, served for service_role and sb_secret_ | tested, one project | 401 Invalid API key, 401 Secret API key required, 200 and 200, same statuses with apikey alone or with Authorization; the announcement’s Access to schema is forbidden text not seen (DD04) |
pg_graphql is absent on a new project and /graphql/v1 answers HTTP 200 with an errors body; introspection needs the schema comment | tested, one project | { __typename } probe in absent, enabled and opted-in states; __schema and __type Unknown field until the comment, refused again with "introspection": false (DD02) |
CREATE EXTENSION ... VERSION is ignored with a warning; some Realtime-schema operations the changelog lists as refused were allowed | tested, one project | postgres role, is_superuser off: version '1.5' and '9.9' both installed 1.6; ALTER EXTENSION ... UPDATE errored XX000; 6 of 10 Realtime operations refused, 4 allowed (DD03); Realtime service version not read |
MCP confirmations are asked only of a client declaring elicitation.form in params._meta | tested, hosted server, one client per shape | L0, L1, S0, SU: 20 of 20 ran unconfirmed; S1 asked, and declined or cancelled ran nothing; skip_elicitations per tool; one accepted requestState ran the query again; read_only=true 25006 (K07) |
logs.all answers 410 since 2026-09-23; S18 holds on /analytics/endpoints/logs | tested | W27b logs_all_http 410; S18 port second run 5 of 5, first run’s S18b row not found in 264 s, cause open (S18 port, edge-resilience RUNLOG) |
supabase_read_only_user has BYPASSRLS | tested | role attribute read on the governed-starter-kit project, 2026-10-07 (RUNLOG); a Read-only member’s session not exercised |
| Scope of Network Restrictions, SSL enforcement, PrivateLink, the Read-only role, MCP read-only mode and the SLA | documented | the pages cited in What each platform control covers; the MCP role inferred from the server source and the API spec, not measured |
db-pre-request does not fire on hosted | tested | GUC set + NOTIFY, no pre-request within 120s (L09) or 61s (S16); none within 182s after POST /restart (S16); fires on own PostgREST (S04) |
The hosted edge appends to x-forwarded-for and passes cf-connecting-ip | tested | RPC returning request.headers: 1 address without a client header, 2 with, client value first (S16) |
A client-supplied cf-connecting-ip never reaches SQL | tested | same RPC carrying cf-connecting-ip: 203.0.113.9 -> 403 at the edge; without a client header the key is present and equals the sole x-forwarded-for element (S22a) |
| A header check works as an RLS policy on hosted, and the header choice decides whether it holds | tested | one allowlist, one forged request, two policies: cf-connecting-ip 0 rows, split_part(x-forwarded-for, ',', 1) 1 row (S22b) |
A zero-argument immutable function reading request.headers is folded at plan time | tested | EXPLAIN: One-Time Filter: false with no current_setting and no scan, against a stable copy whose GUC read survives (S22c) |
| The security advisor does not lint extension-owned objects | tested | pg_tle install of unpinned-search_path functions + an RLS-less table: lints 8 -> 9, the one added being extension_in_public, none naming an hdr object; same-response control of plain-SQL objects of the same shapes drew function_search_path_mutable and rls_disabled_in_public (S22d) |
FORCE RLS binds only a non-BYPASSRLS owner; service_role unaffected; a NOBYPASSRLS role via a minted JWT gets RLS | tested | owner reads 2/2 (postgres) vs 2/0 (lab role) before/after FORCE; service_role 200 with 2 rows; minted-role read 0 then 1 (S17) |
| Anon-only REVOKE leaves RPC open; PUBLIC revoke closes it; only the global default-privilege revoke holds for new functions | tested | anon RPC 200 -> 401 42501 after REVOKE ... FROM public; new function 200 after the per-schema form, 401 after the global form (S21) |
| Exposed schema is project-wide | tested | db_schema = api: service_role on a public table 404 PGRST205 (S21) |
| HIBP fires at signup; anonymous rate limit is exact; CAPTCHA gates signup and login; a Postgres-function hook rejects with its message | tested | 422 weak_password; 3 x 200 then 12 x 429; 400 captcha_failed under the Turnstile always-fail secret, 200 under always-pass; 400 with the hook’s text (S19) |
Requests and logins are findable in edge_logs / auth_logs with a client address; admin audit empty; bans are DB-socket only | tested | marked requests found in 15-18s with a client-address header field; login auth_event with the email; /auth/v1/admin/audit 0 entries; 10 failed pooler auths -> 1 ban, DELETE -> 0 (S18) |
| Own-PostgREST filter sees the client header direct, the edge’s address through an overwriting proxy | tested | RPC value contains the client header direct, one RFC 1918 address via nginx; ban 403 direct, 200 via edge (S20) |
| CORS / custom domain gate nothing server-side | tested | no-Origin request returns data; origin serves after custom domain (L13) |
| IAP-as-issuer admits only the IAP token | tested | third-party auth + iss-keyed RLS; anon and GoTrue read 0 rows (L10, L10E) |
| Own PostgREST + IP filter over the same Postgres | tested | Docker PostgREST v16.2, db-pre-request rejects spoofed x-forwarded-for |
| A column grant closes a column an UPDATE policy leaves open | tested | REVOKE UPDATE + GRANT UPDATE (col), write to a withheld column returns 401/42501 (S13); the mechanism is standard Postgres GRANT |
| Auth switch-on levers (hook, CAPTCHA, configurable rate limits) present and settable | tested | before-user-created hook + CAPTCHA + seven rate_limit_* fields present, one rate limit PATCHed down and back (S14) |
| Storage/Realtime answer with the Data API off - not behind PostgREST | tested | schema wedged empty (503 PGRST002 on a table path), /storage/v1 returns 200 and /realtime/v1 answers (S15) |
| PrivateLink is Team/Enterprise, socket-only | asserted | PrivateLink docs; measured in supabase-aws-privatelink |
Two disagreements are recorded above: the 404 PGRST205 of the SELECT-only revoke against the 42501 of the four-privilege revoke, and a removed logs.all still listed in the OpenAPI document. Filed upstream: no, for either.
Modules
Section titled “Modules”| Module | Experiment | Test | Artifact |
|---|---|---|---|
| DA02 | data-api-reenable | da02-hold-sweep.ts | out/2026-09-24 |
| DA03 | data-api-reenable | da03-dashboard-reenable.ts | out/2026-09-24 |
| DA04 | data-api-reenable | da04-schema-size.ts | out/2026-09-24 |
| DD01 | data-api-defaults | dd01-default-grants.ts | none published |
| DD02 | data-api-defaults | dd02-pg-graphql.ts | none published |
| DD03 | data-api-defaults | dd03-platform-lockdowns.ts | none published |
| DD04 | data-api-defaults | dd04-openapi-key-matrix.ts | none published |
| K07 | governed-starter-kit | k07-mcp-elicitation.ts | none published |
| L05 | iap-lockdown | l05-key-revocation.ts | none published |
| L08 | iap-lockdown | l08-grant-lockdown.ts | none published |
| L09 | iap-lockdown | l09-prerequest-and-api-enum.ts | none published |
| L10 | iap-lockdown | l10-tpa-iap-issuer.ts | none published |
| L10E | iap-lockdown | l10e-labissuer-admit.ts | none published |
| L11 | iap-lockdown | l11-worker-access-proxy.ts | none published |
| L13 | iap-lockdown | l13-custom-domain.ts | none published |
| L22 | iap-lockdown | l22-ef-under-restrictions.ts | none published |
| S01 | security-lockdown | s01-security-advisors.ts | none published |
| S02 | security-lockdown | s02-network-restrictions.ts | none published |
| S03 | security-lockdown | s03-auth-hardening.ts | none published |
| S04 | security-lockdown | s04-self-hosted-postgrest.ts | none published |
| S05 | security-lockdown | s05-rate-limit.ts | none published |
| S06 | security-lockdown | s06-postgrest-role.ts | none published |
| S07 | security-lockdown | s07-vault.ts | none published |
| S08 | security-lockdown | s08-pg-net-egress.ts | none published |
| S09 | security-lockdown | s09-audit-backups.ts | none published |
| S10 | security-lockdown | s10-socket-lock.ts | none published |
| S11 | security-lockdown | s11-auth-efficacy.ts | none published |
| S12 | security-lockdown | s12-ratelimit-worker.ts | none published |
| S13 | security-lockdown | s13-column-grants.ts | none published |
| S14 | security-lockdown | s14-auth-switch-on.ts | none published |
| S15 | security-lockdown | s15-storage-realtime-reach.ts | none published |
| S16 | security-lockdown | s16-prerequest-restart.ts | out/2026-09-03 |
| S17 | security-lockdown | s17-force-rls.ts | out/2026-09-03 |
| S17a | security-lockdown | s17-force-rls.ts | out/2026-09-03 |
| S18 | security-lockdown | s18-audit-and-bans.ts | out/2026-09-03 |
| S18 (port, 2026-10-10) | security-lockdown | s18-audit-and-bans.ts | none published |
| S19 | security-lockdown | s19-auth-enforcement.ts | out/2026-09-03 |
| S20 | security-lockdown | s20-xff-trust-boundary.ts | out/2026-09-03 |
| S21 | security-lockdown | s21-no-rls-backend-only.ts | out/2026-09-03 |
| S22 | security-lockdown | s22-header-keyed-checks.ts | none published |
| S22a | security-lockdown | s22-header-keyed-checks.ts | none published |
| S22b | security-lockdown | s22-header-keyed-checks.ts | none published |
| S22c | security-lockdown | s22-header-keyed-checks.ts | none published |
| S22d | security-lockdown | s22-header-keyed-checks.ts | none published |
| A01d | audit-integrity | a01-privilege-map.ts | out/2026-09-08 |
| A03a-A03c | audit-integrity | a03-silent-delete.ts | out/2026-09-08 |
| MS01a | medium-serverless | ms01-surface-facts.ts | out/2026-09-30 |
| MS03 | medium-serverless | ms03-restriction-paths.ts | out/2026-09-30 |
| W27b, W27d | edge-resilience | w27-pgrst303-edge-logs.ts | none published |
References
Section titled “References”-
Supabase, “Network Restrictions,” Supabase Docs. https://supabase.com/docs/guides/platform/network-restrictions ↩ ↩2 ↩3 ↩4
-
Supabase, “PrivateLink,” Supabase Docs. https://supabase.com/docs/guides/platform/privatelink ↩ ↩2 ↩3 ↩4
-
Supabase, “Securing your API,” Supabase Docs. https://supabase.com/docs/guides/api/securing-your-api ↩ ↩2 ↩3 ↩4
-
Supabase, “Realtime Authorization,” Supabase Docs. https://supabase.com/docs/guides/realtime/authorization ↩
-
Supabase, “Custom Domains,” Supabase Docs. https://supabase.com/docs/guides/platform/custom-domains ↩
-
Supabase, “Breaking Change: Removing access to OpenAPI spec via the anon key,” supabase GitHub Discussions #42949, 2026-02-17. https://github.com/orgs/supabase/discussions/42949 ↩
-
Supabase, “Breaking Change: pg_graphql no longer enabled automatically,” supabase GitHub Discussions #42180, 2026-01-26. https://github.com/orgs/supabase/discussions/42180 ↩
-
Supabase, “Breaking change in pg_graphql 1.6.0 - GraphQL introspection disabled by default,” supabase GitHub Discussions #46320, 2026-05-25. https://github.com/orgs/supabase/discussions/46320 ↩
-
Supabase, “Before User Created Hook,” Supabase Docs. https://supabase.com/docs/guides/auth/auth-hooks/before-user-created-hook ↩
-
Supabase, “Enable CAPTCHA Protection,” Supabase Docs. https://supabase.com/docs/guides/auth/auth-captcha ↩
-
Cloudflare, “Testing,” Cloudflare Turnstile Docs. https://developers.cloudflare.com/turnstile/troubleshooting/testing/ ↩
-
Supabase, “Rate Limits,” Supabase Docs. https://supabase.com/docs/guides/auth/rate-limits ↩
-
Supabase, “Postgres SSL Enforcement,” Supabase Docs. https://supabase.com/docs/guides/platform/ssl-enforcement ↩ ↩2 ↩3
-
Supabase, “PrivateLink,” Supabase Features. https://supabase.com/features/privatelink ↩ ↩2
-
Supabase, “Service Level Agreement.” https://supabase.com/sla ↩ ↩2 ↩3
-
Supabase, “Access Control,” Supabase Docs. https://supabase.com/docs/guides/platform/access-control ↩ ↩2
-
PostgreSQL, “Predefined Roles,” PostgreSQL Documentation. https://www.postgresql.org/docs/current/predefined-roles.html ↩
-
Supabase, “Supabase MCP Server,” Supabase Docs. https://supabase.com/docs/guides/getting-started/mcp ↩ ↩2
-
Supabase, “api-platform.ts,” supabase/mcp, commit 8a26508. https://github.com/supabase/mcp/blob/8a2650891e2fa6e5e895075663ba4f35867083d6/packages/mcp-server-supabase/src/platform/api-platform.ts ↩
-
Supabase, “Management API OpenAPI document,” api.supabase.com, as served on 2026-10-08. https://api.supabase.com/api/v1-json ↩
-
Supabase, “Extension version pinning is deprecated in favor of default versions,” Supabase Changelog, 2026-07-22. https://supabase.com/changelog/extension-version-pinning-ignored ↩
-
Supabase, “Realtime schema is now fully locked down against modifications,” Supabase Changelog, 2026-07-14. https://supabase.com/changelog/realtime-schema-locked-down-against-modification ↩
-
Anthropic, “Hooks reference” (Elicitation), Claude Code Docs. https://code.claude.com/docs/en/hooks ↩
-
Supabase, “Breaking Change: Tables not exposed to Data and GraphQL API automatically,” supabase GitHub Discussions #45329, 2026-04-28. https://github.com/orgs/supabase/discussions/45329 ↩ ↩2
-
PostgreSQL, “ALTER DEFAULT PRIVILEGES,” PostgreSQL Documentation. https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html ↩
-
PostgreSQL, “GRANT,” PostgreSQL Documentation. https://www.postgresql.org/docs/current/sql-grant.html ↩
-
Supabase, “JSON Web Token (JWT),” Supabase Docs. https://supabase.com/docs/guides/auth/jwts ↩
-
PostgREST, “Pre-Request,” PostgREST Documentation. https://postgrest.org/en/stable/references/transactions.html#pre-request ↩
-
Cloudflare, “HTTP request headers,” Cloudflare Fundamentals. https://developers.cloudflare.com/fundamentals/reference/http-headers/ ↩
-
Supabase, “Rate Limiting Edge Functions,” Supabase Docs. https://supabase.com/docs/guides/functions/examples/rate-limiting ↩
-
Supabase, “Log Drains,” Supabase Docs. https://supabase.com/docs/guides/telemetry/log-drains ↩
-
Supabase, “Migration of Supabase Management API logs.all analytics endpoint to logs endpoint,” Supabase Changelog, 2026-07-23. https://supabase.com/changelog/48235-migration-of-supabase-management-api-logs-all-analytics-endpoint-to-logs-endpoint ↩
-
Supabase, “Postgres log configurations,” Supabase Docs. https://supabase.com/docs/guides/database/postgres/postgres-log-config ↩
-
Supabase, “Postgres connection logging,” Supabase Docs. https://supabase.com/docs/guides/platform/postgres-connection-logging ↩
-
Supabase, “List all network bans” and “Remove network bans,” Management API reference (Database section; scopes
database_network_bans_read/database_network_bans_write). https://supabase.com/docs/reference/api/v1-list-all-network-bans and https://supabase.com/docs/reference/api/v1-delete-network-bans. The earlier “Network Bans” guide at /docs/guides/platform/network-bans returned 404 on 2026-10-09. ↩