Skip to content

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 pointed at that fact, and resolves to one of three moves: push the control into the database, lock the database socket, or own the API layer. This is which move fits which goal, and what each costs.

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). A 2026-08-31 follow-up run on the same micro shape added the column-grant, Auth switch-on, and Storage/Realtime-reach probes (S13-S15), and a 2026-09-03 run added six more (S16-S21): the pre-request hook across a project restart and the header shape the hosted edge hands SQL, FORCE ROW LEVEL SECURITY, the audit trail and network bans, Auth enforcement rather than settability, the x-forwarded-for trust boundary on your own PostgREST, and the no-RLS shape where a backend uses service_role and nothing else. 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 moves:
    • 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”. The one place a network perimeter exists.
    • 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_jwt checks key-possession not identity; disabling legacy keys leaves the new publishable keys working; CORS and a custom domain gate nothing server-side.
managed projectany client(browser, curl, backend)HTTP tierData API / Auth / Storage /Realtime / Edge Functions<ref>.supabase.coalways publicPostgres socket5432 direct / 6543 poolernetwork restrictionsPrivateLinklockableno network leverper-service tighten only

The socket has a network perimeter - network restrictions and PrivateLink act on it.12 The HTTP tier has none: it answers a public endpoint and every lever on it is per-service tightening, so hardening it means moving the control off the edge. The per-service levers, measured, each closing one service and leaving the rest answering:

LeverWhat it closesWhat stays open
Empty exposed schema (“Data API off”), set with PATCH .../postgrestPostgREST REST + GraphQL (503 PGRST002 on a table path; /rest/v1/ root still answers 401)Auth, Storage, Realtime, Edge Functions3
db_schema drops graphql_publicGraphQL only (406 PGRST106)REST
max_rows = 1caps rows per responsea bulk-read brake, not authz
Realtime private_only = truepublic-channel joins (refused at join)the WebSocket still upgrades; anon socket connects4
disable_signup = truenew signupsan 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 disabledthe legacy anon/service pairthe new publishable key generation still reads
Bucket set privatethe public object URL (400 NoSuchBucket)a service-key signed URL still serves the object
Edge Function verify_jwt = truecallers with no key (401)any valid project JWT, incl. the anon key

The two levers most often mistaken for gates are the two that 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 table reads Auth for what leaks; the same service has levers a customer switches on, and none of them is a tier gate either: each hardens Auth alone.

LeverWhat it doesMeasured
Before-user-created hook6runs your code on the signup path before the row exists; a domain allowlist or a fraud check rejects the account at the doorpresent 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)7gates the signup, sign-in and password-reset entry points against scripted abusepresent 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)8
Auth rate limits9token refresh, OTP, email and SMS sends, verify, and anonymous sign-ins each take a lower ceiling than the shared defaultthe 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 TOTPpassword policy and second factorset 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_enabledleaked-password protectionoff 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 measured the switch-on fields on a micro project (2026-08-31) and S19 drove them to their refusals (2026-09-03). Two notes from S19: the rate-limit count is exact, with no burst above the configured value in that window; and 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. Set password_min_length to 12 or more and keep MFA verify on.

Before picking a move, 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.

GoalMoveMechanismCost
Close data to anon, keep the managed tierinto the databasegrants, RLS, claims-keyed identityauthz logic lives in Postgres
A private database pathlock the socketPrivateLink + “restrict all”Team/Enterprise; AWS VPC in-region2
IP-restricted or rate-limited RESTown the API layeryour own PostgREST, Data API offyou run PostgREST
The whole HTTP tier privateown everythingself-host / BYOCyou run the stack

Move 1: push the control into the database

Section titled “Move 1: push the control into the database”

The boundary is the row, so the control belongs on the row. Three levers live here - grants, RLS, and the token - and the section ends with the three places the move stops.

For a table that must shut to anon without a policy migration, the grant is the lever. It rots on the next migration unless the default privileges move with it (iap-lockdown L08).

StepEffect through the Data APIRests on
REVOKE SELECT ON <table> FROM anon, authenticatedanon gets 42501; service_role keeps readingL08
The next CREATE TABLEpg_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 grantornew tables arrive closed (404 PGRST205)L08
The same for the supabase_admin grantornot alterable by the project owner (42501); platform-created objects keep their default anon grantsL08

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), and the revoke everyone reaches for first leaves the RPC surface open.

Stepanon tableauthenticated tableanon RPCservice_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 public401 42501: permission denied for table403 42501: permission denied for table200 - still callabletable 200, 2 rows
2. REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM public--401 42501: permission denied for functionRPC 200
A table and a function created after steps 1-2table 401 42501-function 200 - callable again200
3. ALTER DEFAULT PRIVILEGES FOR ROLE postgres REVOKE EXECUTE ON FUNCTIONS FROM public (global, no IN SCHEMA), then another new function--401 42501200

Why the first step misses functions, and why the per-schema default does not hold:

  • Functions carry EXECUTE for PUBLIC (proacl {=X/postgres,...}) and public keeps USAGE for PUBLIC, so a revoke aimed at anon and authenticated never touches them.
  • Per-schema default privileges add to the global default and cannot remove PUBLIC’s built-in EXECUTE;10 only the global form does. The table created after the schema-level revoke stayed closed, because tables have no PUBLIC default.

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.

RLS is the row-level version of the same move. Its write-side traps are what make “we added RLS” still fail a pen test: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 CHECK constrains 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.

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.11 RLS decides the rows; the column grant decides the columns. Measured as the pair in security-lockdown S13 (2026-08-31):

Setupanon write to balanceanon write to the granted column
Permissive UPDATE policy + table-level UPDATE grant204 - the hole a pen test writes through204
Same policy, REVOKE UPDATE + GRANT UPDATE (note)401 carrying SQLSTATE 42501204

FORCE ROW LEVEL SECURITY binds less than its name says

Section titled “FORCE ROW LEVEL SECURITY binds less than its name says”

FORCE is the lever that comes up next for “we want RLS applied to the roles our backend uses”. 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:

RoleBYPASSRLSNote
postgresyesthe default owner; the role the Management query endpoint runs as (S17a; the Dashboard SQL editor is documented to use the same role)
service_roleyesthe backend key
supabase_adminyesplatform
authenticator, anon, authenticated, supabase_auth_adminno
Reader, RLS on and no policyRows before FORCERows after FORCE
postgres on a postgres-owned table22
a lab role without BYPASSRLS on a table it owns20
service_role through REST200, 2 rows200, 2 rows

ALTER ROLE service_role NOBYPASSRLS as the project owner returned 42501: "service_role" is a reserved role, only superusers can modify it. The shape that does apply RLS to a backend is role choice, in three steps:

  1. Create a role NOBYPASSRLS and grant it to authenticator.
  2. Mint the backend’s HS256 JWT with that role in the role claim, signed with the secret GET /projects/{ref}/postgrest returns.
  3. Call the Data API with it: 200 with 0 rows under no policy, 200 with 1 row once a tenant = 'a' policy existed.

The backend gets RLS by connecting as a role that cannot bypass it; FORCE does not reach a BYPASSRLS owner.

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.12 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 key0
GoTrue user token0
token minted by the trusted issuerits 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.

Three limits, each measured.

The IP filter that does not fire on hosted

Section titled “The IP filter that does not fire on hosted”

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 PGRST002 on a table path), /storage/v1 still served (200) and /realtime/v1 still answered - each is its own service against the same Postgres, which is why “Data API off” leaves both up (the first row of the lever table). 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 on storage.objects, Realtime Authorization - or on an edge you own.
  • The public-schema REVOKE does not reach storage. The schema is owned by supabase_admin, not the project owner (measured), so its tables keep their own grants and Storage stays governed by RLS on storage.objects.
  • Claims read straight from the wire have their own edge. The claims GUC is unprivileged, so the connection credential becomes the boundary - worked through with a tampered-claim test in RLS without Supabase Auth.

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.

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 move does nothing for the HTTP tier: PrivateLink covers Postgres and the pooler only, and the Data API stays public by design.

Two measurements to carry into it. Through the 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. 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 move 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.

When the goal is an IP-restricted, rate-limited, or otherwise network-controlled REST layer, the managed tier has run out and the answer is to take the API layer in-house: turn the Data API off (PATCH /v1/projects/{ref}/postgrest with db_schema: "", never the dashboard toggle - its 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) and run PostgREST yourself against the same Postgres, behind an edge you control. Measured 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 (S04). The filter judges whatever header reaches it, which is the deployment’s problem to settle (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.

clientyour edgeIP allowlist / WAF /rate limitprivate pathyour PostgRESTdb-pre-requestprivate pathSupabase Postgres(pooler, PrivateLink)private pathmanaged Data APIOFFwedged

The proxy-only variant of this move - a Worker in front of the managed origin - is bypassable until you finish the job. 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 rides 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.15 It only counts traffic through it, so it works in front of a closed origin, never the managed endpoint that always answers a key-holder.

The secrets this move creates - the service key the Worker holds, the connection role’s password - have a home 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.

EventWhere it appearsMatched onLag
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 headersmetadata.request.path18s
service-key GET /storage/v1/bucket?sec18=<nonce> (200)edge_logs, same fieldsmetadata.request.url15s
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 emailpath and error_code2s
successful login (200)auth_logs, as an auth_event line; the email was in actor_username in the row read backaction: login plus the email3s
either loginGET /auth/v1/admin/audit with the service key-returned 200 with 0 entries throughout

Two facts about the plumbing: the /v1 OpenAPI spec has 115 paths and none mentions a drain, so Log Drains are configured in the Dashboard only;16 and the lab queried /analytics/endpoints/logs.all, because the sibling /analytics/endpoints/logs answered Backend error! Retry your query. to the same SQL on that day.

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.17

StepResult
POST network-bans/retrieve before0 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 address200; 0 banned after
correct-password psql through the pooler afterwardssucceeded

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.

What has to be private?Just the database socket?Identity on the Data API,keep the managed tier?noMove 2:PrivateLink + restrict-allyesIP / rate-limit the REST layer?noMove 1:third-party auth + claim-keyed RLSyesMove 3:your own PostgREST, Data API offyesself-host / BYOC(whole stack private)need the whole HTTP tier private

The identity half of every move 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 buy - is measured in one place in Supabase Auth end to end.

The measurements above imply a short list of practices. 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.

PracticeRests on
Read the Management API security advisor before choosing a move 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 Move 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 and expect FATAL (EADDRNOTALLOWED) address not in tenant.S10
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 via 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 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; the API round-trips what you give it.http-tier-lockdown run 2
Verify “REST off” on a table path: 503 PGRST002 appears there only, while /rest/v1/ root answers 401 in both states.S15
Keep the service key and connection-role password a lockdown creates in supabase_vault, which stores ciphertext in vault.secrets and returns it through vault.decrypted_secrets.S07
Use pgaudit for the evidence side (statement auditing to the Postgres log, read through the Management API logs) and check GET /database/backups before relying on point-in-time recovery; a fresh project reports pitr_enabled=false, walg_enabled=true.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
Treat the exposed-schema move as project-wide: with db_schema set to api alone, service_role reading a public table got 404 PGRST205. Move the backend’s objects, or its calls, before flipping it.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
Read the client address from cf-connecting-ip in any header-keyed check on the managed tier (S16 saw the key reach SQL; the edge appends to x-forwarded-for with the client’s value first, so the first x-forwarded-for element is attacker-controlled).S16
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 rate_limit_anonymous_users (and the other rate_limit_* fields) to the count you mean: a value of 3 gave 3 x 200 then 429, no burst above it.S19
Build the audit trail on edge_logs (metadata.request.path, metadata.request.headers.cf_connecting_ip or x_real_ip) and auth_logs (auth_event.actor_username for logins, request lines with error_code for failures) through /analytics/endpoints/logs.all; /auth/v1/admin/audit returned 0 entries, and Log Drains have no /v1 path.S18
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 and have it 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

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 move 3 - it is not a managed capability, the DB-layer workaround does not fire on hosted, so the REST layer has to be one you own. What generalises is the boundary, not 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).

Every measured claim comes out of two disposable supabase-lab experiments. 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, Vault, pg_net-egress and pgaudit checks, the review gap-plugging 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/. Both provision, probe, and destroy in one run; region and plan are variables.

ClaimStatusHow it was checked
Data API off = PostgREST 503, other services uptestedempty db_schema via PATCH /postgrest, full-surface re-probe
Network restrictions gate the socket, not the HTTP tiertestedrestrictive CIDR applied, REST unchanged (network restrictions)
verify_jwt passes any project JWTtestedanon-key call to a verify_jwt=true function returns 200
Legacy-key disable leaves publishable keys workingtestedPUT /api-keys/legacy?enabled=false, publishable key still reads
Grants close a table; default privileges reopen new onestestedREVOKE + CREATE TABLE + ALTER DEFAULT PRIVILEGES, probed via /rest/v1
db-pre-request does not fire on hostedtestedGUC 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-iptestedRPC returning request.headers: 1 address without a client header, 2 with, client value first (S16)
FORCE RLS binds only a non-BYPASSRLS owner; service_role unaffected; a NOBYPASSRLS role via a minted JWT gets RLStestedowner 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 functionstestedanon 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-widetesteddb_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 messagetested422 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 onlytestedmarked 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 proxytestedRPC 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-sidetestedno-Origin request returns data; origin serves after custom domain
IAP-as-issuer admits only the IAP tokentestedthird-party auth + iss-keyed RLS; anon and GoTrue read 0 rows
Own PostgREST + IP filter over the same PostgrestestedDocker PostgREST v16.2, db-pre-request rejects spoofed x-forwarded-for
A column grant closes a column an UPDATE policy leaves opentestedREVOKE 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 settabletestedbefore-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 PostgRESTtestedschema wedged empty (503 PGRST002 on a table path), /storage/v1 returns 200 and /realtime/v1 answers (S15)
PrivateLink is Team/Enterprise, socket-onlyassertedPrivateLink docs; measured in supabase-aws-privatelink
  1. Supabase, “Network Restrictions,” Supabase Docs. https://supabase.com/docs/guides/platform/network-restrictions 2

  2. Supabase, “Securing your API,” Supabase Docs. https://supabase.com/docs/guides/api/securing-your-api 2

  3. Supabase, “Realtime Authorization,” Supabase Docs. https://supabase.com/docs/guides/realtime/authorization

  4. Supabase, “Custom Domains,” Supabase Docs. https://supabase.com/docs/guides/platform/custom-domains

  5. Supabase, “Before User Created Hook,” Supabase Docs. https://supabase.com/docs/guides/auth/auth-hooks/before-user-created-hook

  6. Supabase, “Enable CAPTCHA Protection,” Supabase Docs. https://supabase.com/docs/guides/auth/auth-captcha

  7. Cloudflare, “Testing,” Cloudflare Turnstile Docs. https://developers.cloudflare.com/turnstile/troubleshooting/testing/

  8. Supabase, “Rate Limits,” Supabase Docs. https://supabase.com/docs/guides/auth/rate-limits

  9. PostgreSQL, “ALTER DEFAULT PRIVILEGES,” PostgreSQL Documentation. https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html

  10. PostgreSQL, “GRANT,” PostgreSQL Documentation. https://www.postgresql.org/docs/current/sql-grant.html

  11. Supabase, “JSON Web Token (JWT),” Supabase Docs. https://supabase.com/docs/guides/auth/jwts

  12. PostgREST, “Pre-Request,” PostgREST Documentation. https://postgrest.org/en/stable/references/transactions.html#pre-request

  13. Cloudflare, “HTTP request headers,” Cloudflare Fundamentals. https://developers.cloudflare.com/fundamentals/reference/http-headers/

  14. Supabase, “Rate Limiting Edge Functions,” Supabase Docs. https://supabase.com/docs/guides/functions/examples/rate-limiting

  15. Supabase, “Log Drains,” Supabase Docs. https://supabase.com/docs/guides/telemetry/log-drains

  16. Supabase, “Network Bans,” Supabase Docs. https://supabase.com/docs/guides/platform/network-bans