Put an IAP over the Supabase Data API
You’ll make a Supabase project’s Data API serve only requests that carry a token from an identity provider you nominate - an Identity-Aware Proxy (IAP) over the data at the authorisation layer. This is Option 1 in Locking down Supabase: the database is the boundary: the Data API has no network gate, so identity is enforced on the row via third-party auth, keyed on a claim only the issuer sets. It is plan-agnostic.
What you need, and where it is
Section titled “What you need, and where it is”Run every SQL block in the SQL Editor (Dashboard -> SQL Editor).
| Value | Where to get it |
|---|---|
$REF - the project ref | your project URL, or Settings -> API |
$ANON - the anon (or publishable) API key | Settings -> API, explained in Understanding API keys |
$PAT - a personal access token for the Management API | the dashboard, under Account -> Access Tokens |
$ISSUER + a token signed by it | your identity provider (Cloudflare Access, Auth0, Okta, Authentik), or the self-mint path in step 1 for testing |
“GoTrue” below is Supabase Auth, the built-in auth server. anon and authenticated are Postgres roles Supabase ships; auth.jwt() reads the verified token’s claims inside a policy. Measured on a throwaway micro project on 2026-08-28.
Step 1: have an issuer with a reachable JWKS
Section titled “Step 1: have an issuer with a reachable JWKS”Supabase resolves third-party auth by fetching the issuer’s JWKS. Two shapes register; a third does not:
oidc_issuer_url- the issuer’s base URL; Supabase reads<issuer>/.well-known/openid-configurationfor thejwks_uri. Resolves in tens of milliseconds.jwks_url- a direct JWKS URL. Resolves.custom_jwks- an inline key set. Accepted but never resolves; do not use it.
For Cloudflare Access, create an Access-for-SaaS OIDC application; its issuer is https://<team>.cloudflareaccess.com/cdn-cgi/access/sso/oidc/<client_id>, with a working discovery document. Getting a token from it means a user login (step 4 note).
For testing without an IdP (a token you can produce on demand), be your own issuer: generate an ES256 keypair, serve its public JWKS from an Edge Function, and sign tokens with the private half.
Generate the keypair:
// keygen.mjs - run: node keygen.mjsimport { generateKeyPairSync, randomUUID } from "node:crypto";const kid = randomUUID().replaceAll("-", "");const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });const pub = { ...publicKey.export({ format: "jwk" }), kid, alg: "ES256", use: "sig" };const priv = { ...privateKey.export({ format: "jwk" }), kid, alg: "ES256", use: "sig" };console.log(JSON.stringify({ pub, priv }, null, 2));Serve the public half as a JWKS from an Edge Function - its URL is your jwks_url:
// supabase/functions/jwks/index.ts - deploy: supabase functions deploy jwks --no-verify-jwtconst PUBLIC_JWK = { /* paste pub from keygen */ };Deno.serve(() => Response.json({ keys: [PUBLIC_JWK] }));Sign a token with the private half. role and aud must be authenticated, and iss is what you key RLS on in step 3:
// mint.mjs - run: node mint.mjs (prints $IAP_TOKEN for step 4)import { createPrivateKey, createSign } from "node:crypto";const PRIV_JWK = { /* paste priv from keygen */ };const ISS = "https://lab-issuer";const b64 = (b) => Buffer.from(b).toString("base64url");const now = Math.floor(Date.now() / 1000);const header = { alg: "ES256", typ: "JWT", kid: PRIV_JWK.kid };const payload = { role: "authenticated", iss: ISS, sub: "user-1", aud: "authenticated", iat: now, exp: now + 3600 };const input = b64(JSON.stringify(header)) + "." + b64(JSON.stringify(payload));const sig = createSign("sha256").update(input).sign({ key: createPrivateKey({ key: PRIV_JWK, format: "jwk" }), dsaEncoding: "ieee-p1363" });console.log(input + "." + b64(sig));Register the Edge Function URL as jwks_url (not oidc_issuer_url) in step 2, and use ISS as the policy’s iss in step 3.
Step 2: register the issuer as third-party auth
Section titled “Step 2: register the issuer as third-party auth”curl -s -X POST "https://api.supabase.com/v1/projects/$REF/config/auth/third-party-auth" \ -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \ -d '{"oidc_issuer_url": "'"$ISSUER"'"}'Confirm it resolved - the created integration carries a non-null resolved_jwks / resolved_at, either on the create response or within a few seconds on GET .../third-party-auth.
Step 3: key RLS on the issuer claim
Section titled “Step 3: key RLS on the issuer claim”Enable RLS and admit only a JWT whose iss is your issuer. Anon and GoTrue-issued tokens carry a different iss, so the same policy filters them out:
alter table public.docs enable row level security;grant select on public.docs to anon, authenticated;create policy iap_only on public.docs for select using ((auth.jwt() ->> 'iss') = 'https://your-issuer');Key on a group or email claim instead of iss when you want per-identity policies rather than “any identity from this IdP”.
-
New tables arrive open on the legacy default privileges. Measured on 2026-10-10 on a project created through
POST /v1/projects(data-api-defaults DD01a, DD01b):pg_default_aclforpostgresinpublicgrantedarwdDxtmon tables toanon,authenticatedandservice_role, and a table made in SQL read200with its row for anon and for the publishable key, with noGRANT. iap-lockdown L08 saw a new table anon-readable with noGRANTon a project created with OpenTofu on 2026-08-28; that run did not read the default ACL itself. A table created after this step is therefore anon-readable through the Data API with no policy at all;service_rolealso reads it, via its own grant and BYPASSRLS. The public notice documents a default that exposes nothing on new projects from 2026-05-30;1 the API-created project did not show it, and the lab did not separate the creation path from a gradual rollout. Readpg_default_aclon your own project rather than assuming either state. Run one of these aspostgres, and enable RLS on every table you expose, not onlydocs. The two answer differently once a new table exists:alter default privileges in schema public revoke select on tables from anon, authenticated;(L08, 2026-08-28): a new table answers404 PGRST205until you grant it.- The notice’s four-privilege form (the statement as the probe worded it, plus the sequences form),
alter default privileges for role postgres in schema public revoke select, insert, update, delete on tables from anon, authenticated, service_role;(DD01c, 2026-10-10): a new table answered anon and the publishable key401, andservice_roleand the secret API key403, each with42501 permission denied for table <t>and a hint of the formGrant the required privileges to the current role with: GRANT SELECT ON public.<t> TO anon;. AGRANT SELECTto anon re-opened the table for anon at once and leftservice_roleat403(DD01d), so a backend on the service key needs its own grant on each new table.
The two runs differ in project, date and privilege list, so the split between
404 PGRST205and42501is not attributed to one cause here; the lockdown page’s one-table steps reconcile them. -
Keep
iap_onlythe only PERMISSIVE SELECT policy on the table and add later policiesas restrictive. PERMISSIVE policies OR together, so a second permissive policy added later for another purpose admits anon to the same rows. A RESTRICTIVE policy only narrows what PERMISSIVE policies admit, so makingiap_onlyitselfas restrictivewhile it is the sole policy returns 0 rows to everyone. Neither RESTRICTIVE form was measured. -
For an identity that writes, the policy decides rows and the grant decides columns. A permissive UPDATE policy plus a table-level UPDATE grant let anon overwrite a
balancecolumn (204); afterrevoke update on tandgrant update (note)the same write returned401with SQLSTATE42501whilenotestill wrote (security-lockdown S13). Grant UPDATE per column.
Step 4: verify the three credential classes
Section titled “Step 4: verify the three credential classes”$IAP_TOKEN is the mint.mjs output (self-mint path), or a real token from your IdP’s login. The admit result below was measured with the self-minted ES256 issuer (L10E). The real-IdP case is not measured: the Access login is interactive and the L10d attempt stopped at it. The request sends the project’s anon or publishable key as apikey (the gateway routes on it) and the identity token as the bearer:
# anon key as bearer -> 200, zero rows (RLS filters)curl -s -H "apikey: $ANON" -H "Authorization: Bearer $ANON" \ "https://$REF.supabase.co/rest/v1/docs?select=id"
# IAP-issued token as bearer -> 200, rowscurl -s -H "apikey: $ANON" -H "Authorization: Bearer $IAP_TOKEN" \ "https://$REF.supabase.co/rest/v1/docs?select=id"With the Cloudflare Access issuer registered, anon and a GoTrue user token both returned zero rows (L10); with the lab ES256 issuer registered, the minted token returned its rows and anon still read zero (L10E).
Step 5: close the other identity paths
Section titled “Step 5: close the other identity paths”That covers the Data API, but the project can still mint its own identities through GoTrue. PATCH /v1/projects/{ref}/config/auth with {"disable_signup": true} stops new GoTrue signups. With the issuer registered and signup off, identity on the project exists only through the IAP’s IdP.
Verification
Section titled “Verification”| Check | Expected |
|---|---|
| Third-party auth resolved | resolved_jwks non-null after register |
| Anon key against the RLS table | 200, 0 rows |
| GoTrue user token | 200, 0 rows |
| Token from the self-minted ES256 issuer | 200, rows (L10E) |
| A real token from your IdP’s login | 200, rows expected; not measured - L10d was attempted and needs an interactive login |
| Management API security advisor after step 3 | no rls_disabled_in_public or rls_enabled_no_policy lint on the tables you expose; it caught every seeded exposure in security-lockdown S01 |
What to do about it
Section titled “What to do about it”Module ids are iap-lockdown unless prefixed (DD01 is data-api-defaults, measured 2026-10-10); a row that is a design choice rather than a result says so.
| Practice | Evidence | Module |
|---|---|---|
Run alter default privileges in schema public revoke select on tables from anon, authenticated; as postgres, and enable RLS on every exposed table. | Measured: new tables otherwise arrive anon-readable through pg_default_acl (L08 on 2026-08-28; DD01a and DD01b on an API-created project on 2026-10-10), and after this statement answer 404 PGRST205 until granted (L08). After the notice’s four-privilege revoke they answered 401 or 403 with 42501 and a GRANT hint (DD01c). The lockdown page reconciles the two. | L08, data-api-defaults DD01 |
Create views over the gated table with (security_invoker = true). | A plain view returned every row to anon, the invoker-security view 0. | L08 |
Keep iap_only, the iss policy, as the only PERMISSIVE SELECT policy on the table and add later ones as restrictive. | PERMISSIVE policies OR together and a second one admits anon. A RESTRICTIVE policy only narrows what PERMISSIVE policies admit; if iap_only is the sole policy, making it as restrictive returns 0 rows to everyone. Neither RESTRICTIVE form was measured. | L08 |
| Grant UPDATE per column for a writing identity. | A permissive UPDATE policy plus a table-level grant let anon overwrite balance (204); revoke update on t plus grant update (note) turned the same write into 401 with SQLSTATE 42501. | security-lockdown S13 |
| Verify the IAP token inside any Edge Function you want covered. | verify_jwt = true refused a no-key caller (401) and admitted the anon project key (200). The in-function verification is not measured. | L07 |
| Run the Management API security advisor after step 3. | 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) in the lab. | security-lockdown S01 |
Run step 4 with a real token from your IdP and check that the token carries role: "authenticated" (the gotcha below) before relying on it. | The lab admitted only the self-minted ES256 issuer’s token and never a real Cloudflare Access token (the L10d attempt stopped at the interactive login), so whether your IdP sets that claim is not measured. | L10, L10E |
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”custom_jwksis accepted and never resolves; the issuer must be a reachable URL.- The token needs
role: "authenticated"for PostgREST to switch roles, andaud: "authenticated". A token missing the role claim is rejected before RLS. service_rolebypasses RLS, so a backend holding the service key is not subject to this policy - keep the service key server-side and out of the IAP path.- A plain view over the RLS table returns every row to anon; the same view created
with (security_invoker = true)returned 0 (L08). - Getting a real Cloudflare Access token means completing the interactive one-time-PIN or SSO login; there is no
client_credentialsgrant on an Access SaaS OIDC app. For automated tests, the self-minted ES256 issuer (step 1) needs no browser. - This gates the Data API only. Auth, Storage, Realtime and Edge Functions keep their own public endpoints; a private HTTP tier is Option 3 or self-hosting.
verify_jwt = trueon an Edge Function does not extend the gate: it refused a caller with no key (401) and admitted the anon project key (200), a key-possession check (L07). Verify the IAP token inside the function against the issuer’s JWKS; that in-function check was not measured here.
File reference
Section titled “File reference”The reproducible form is the supabase-lab iap-lockdown experiment: l10-tpa-iap-issuer.ts (Cloudflare Access issuer, resolution, the anon/GoTrue denials) and l10e-labissuer-admit.ts (the self-minted ES256 issuer, JWKS served by an Edge Function, the admit proof).
Modules
Section titled “Modules”| Module | Experiment | Test | Artifact |
|---|---|---|---|
| DD01 | data-api-defaults | dd01-default-grants.ts | RUNLOG.md |
| L07 | iap-lockdown | l07-ef-verify-jwt.ts | none published |
| L08 | iap-lockdown | l08-grant-lockdown.ts | none published |
| L10 | iap-lockdown | l10-tpa-iap-issuer.ts | none published |
| L10E | iap-lockdown | l10e-labissuer-admit.ts | none published |
| L10d | iap-lockdown | l10-tpa-iap-issuer.ts | none published |
| S01 | security-lockdown | s01-security-advisors.ts | none published |
| S13 | security-lockdown | s13-column-grants.ts | none published |
References
Section titled “References”-
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 ↩