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_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 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:
| 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 still answers 401) | 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 |
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 Auth levers, switched on
Section titled “The Auth levers, switched on”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.
| Lever | What it does | Measured |
|---|---|---|
| Before-user-created hook6 | 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)7 | 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)8 |
| Auth rate limits9 | 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 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.
Which move fits the goal
Section titled “Which move fits the goal”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.
| Goal | Move | 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 |
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.
Grants
Section titled “Grants”One table
Section titled “One table”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).
| 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 | new tables arrive closed (404 PGRST205) | L08 |
The same for the supabase_admin grantor | not alterable by the project owner (42501); platform-created objects keep their default anon grants | L08 |
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), and the revoke everyone reaches for first 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 |
Why the first step misses functions, and why the per-schema default does not hold:
- 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;10 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.
The write-side traps
Section titled “The write-side traps”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 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.
Columns are a grant, not a second policy
Section titled “Columns are a grant, not a second policy”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):
| 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 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:
| 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. The shape that does apply RLS to a backend is role choice, in three steps:
- 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.
The backend gets RLS by connecting as a role that cannot bypass it; FORCE does not reach a BYPASSRLS owner.
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.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 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 move stops
Section titled “Where this move stops”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 PGRST002on a table path),/storage/v1still served (200) and/realtime/v1still 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 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 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.
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.
Move 2: lock the database socket
Section titled “Move 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 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.
Move 3: own the API layer
Section titled “Move 3: own the API layer”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.
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.
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 |
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.
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.17
| 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 move to pick
Section titled “Which move to pick”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.
What to do about it
Section titled “What to do about it”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.
| Practice | Rests 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 |
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 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).
Reproducing
Section titled “Reproducing”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.
| 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 |
| Network restrictions gate the socket, not the HTTP tier | tested | restrictive CIDR applied, REST unchanged (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 |
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) |
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 |
| IAP-as-issuer admits only the IAP token | tested | third-party auth + iss-keyed RLS; anon and GoTrue read 0 rows |
| 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 |
References
Section titled “References”-
Supabase, “Network Restrictions,” Supabase Docs. https://supabase.com/docs/guides/platform/network-restrictions ↩ ↩2
-
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
-
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, “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 ↩
-
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, “Network Bans,” Supabase Docs. https://supabase.com/docs/guides/platform/network-bans ↩