Skip to content

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.

Run every SQL block in the SQL Editor (Dashboard -> SQL Editor).

ValueWhere to get it
$REF - the project refyour project URL, or Settings -> API
$ANON - the anon (or publishable) API keySettings -> API, explained in Understanding API keys
$PAT - a personal access token for the Management APIthe dashboard, under Account -> Access Tokens
$ISSUER + a token signed by ityour 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-configuration for the jwks_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.mjs
import { 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-jwt
const 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”
Terminal window
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.

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:

Terminal window
# 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, rows
curl -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.

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.

CheckExpected
Third-party auth resolvedresolved_jwks non-null after register
Anon key against the RLS table200, 0 rows
GoTrue user token200, 0 rows
IAP-issued token200, rows
  • custom_jwks is 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, and aud: "authenticated". A token missing the role claim is rejected before RLS.
  • service_role bypasses 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_credentials grant 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.

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