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 authorization 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”.
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 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: anon and a GoTrue user token both return zero rows; a token minted by the trusted issuer returns its rows. 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 |
| IAP-issued token | 200, rows |
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.- 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.
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).