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 Move 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. Run 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”.
Three things around that policy, measured in iap-lockdown L08 and security-lockdown S13:
- New tables arrive open.
pg_default_aclgrants SELECT on every new table toanonandauthenticated(iap-lockdown L08;service_rolealso reads, via its own grant and BYPASSRLS), so a table created after this step is anon-readable through the Data API with no policy at all. Runalter default privileges in schema public revoke select on tables from anon, authenticated;aspostgres- new tables then answer404 PGRST205until you grant them - and enable RLS on every table you expose, not onlydocs. - Keep
iap_onlythe only PERMISSIVE SELECT policy on the table. PERMISSIVE policies OR together, so a second one added later for another purpose admits anon to the same rows. A RESTRICTIVE policy only narrows what PERMISSIVE policies admit; ifiap_onlyis the sole policy, making itas restrictivereturns 0 rows to everyone. Keepiap_onlyPERMISSIVE and add later policiesas restrictiveinstead. 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 (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); a real Cloudflare Access token was never admitted in the lab, because the Access login is interactive and the L10d attempt stopped there, so treat the real-IdP case as not measured until you have run it yourself. 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"Measured: with the Cloudflare Access issuer registered, anon and a GoTrue user token both return zero rows (L10); with the lab ES256 issuer registered, the minted token returns its rows and anon still reads zero (L10E). The Data API now serves only the IAP identity.
Step 5: close the other identity paths
Section titled “Step 5: close the other identity paths”The Data API is served; the project can still mint its own identities through GoTrue. PATCH /v1/projects/{ref}/config/auth with {"disable_signup": true} stops new GoTrue signups, so 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”The measurements behind the steps imply a short list of practices. Each row names the module id it rests on (iap-lockdown unless prefixed); a row that is a design choice rather than a result says so.
| Practice | Rests on |
|---|---|
Run alter default privileges in schema public revoke select on tables from anon, authenticated; as postgres and enable RLS on every exposed table: new tables otherwise arrive anon-readable through pg_default_acl, and afterwards answer 404 PGRST205 until granted. | L08 |
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 the iss policy the only PERMISSIVE SELECT policy on the table; 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. Keep iap_only PERMISSIVE and add later policies as restrictive instead. 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 before relying on it, and check that the token carries role: "authenticated" (the gotcha below): 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, so an inline key set is a dead end.- 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). A view overdocsundoes step 3 unless it is invoker-security. - 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 Move 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).