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

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

Three things around that policy, measured in iap-lockdown L08 and security-lockdown S13:

  • New tables arrive open. pg_default_acl grants SELECT on every new table to anon and authenticated (iap-lockdown L08; service_role also 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. Run alter default privileges in schema public revoke select on tables from anon, authenticated; as postgres - new tables then answer 404 PGRST205 until you grant them - and enable RLS on every table you expose, not only docs.
  • Keep iap_only the 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; 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.
  • 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 balance column (204); after revoke update on t and grant update (note) the same write returned 401 with SQLSTATE 42501 while note still 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:

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

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
Token from the self-minted ES256 issuer200, rows (L10E)
A real token from your IdP’s login200, rows expected; not measured - L10d was attempted and needs an interactive login
Management API security advisor after step 3no rls_disabled_in_public or rls_enabled_no_policy lint on the tables you expose; it caught every seeded exposure in security-lockdown S01

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.

PracticeRests 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
  • 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.
  • 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 over docs undoes 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_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. verify_jwt = true on 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.

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