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 a Cloudflare Worker on workers.dev. Each lever was applied through the Management API, then every HTTP surface re-probed with three credential classes (no key, anon key, service key). 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.
  • So there are three moves, not a perimeter to harden:
    • 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”)PostgREST REST + GraphQL (503 PGRST002)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
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.

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. For a table that must shut to anon without a policy migration, REVOKE SELECT ON <table> FROM anon, authenticated returns 42501 through the Data API while service_role keeps reading. It rots on the next migration - pg_default_acl grants SELECT on new tables to anon, authenticated and service_role, so a table created after the revoke is anon-readable again. ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT ON TABLES FROM anon, authenticated for the postgres grantor closes new tables on arrival (404 PGRST205); the supabase_admin grantor keeps its own default ACL and the project owner cannot alter it (42501), so platform-created objects keep their default anon grants.

RLS is the row-level version of the same move, and its write-side traps are what make “we added RLS” still fail a pen test: a view over an RLS table leaks every row without security_invoker, an UPDATE policy gates which rows change but not which columns, and PERMISSIVE policies OR together so an admin-intended policy bleeds onto anon.3

Identity on the Data API is the same move with 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.6 Measured with Cloudflare Access-for-SaaS as the real issuer: the issuer resolved on registration, and with a policy of (auth.jwt() ->> 'iss') = '<issuer>', the anon key and a GoTrue user token both read zero rows while a token minted by the trusted issuer reads its rows. That is “an IAP over the data” done at the authorization 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.

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.

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 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. 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), at which point supabase-js from the browser is gone and the Worker owns authorization because service_role bypasses RLS - which is a backend in front of a database, the same shape as running your own PostgREST. 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, WAF rule, or Upstash does the same job.8 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.

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

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, and the connection-role, Vault, pg_net-egress and pgaudit checks. 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; fires on own PostgREST
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
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, “JSON Web Token (JWT),” Supabase Docs. https://supabase.com/docs/guides/auth/jwts

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

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