Cenk KURTOĞLU discusses common issues with Supabase schemas where RLS policies appear configured but still allow data leaks. He identifies three main problems: RLS not enabled on tables, permissive policies overriding restrictive ones, and membership joins not isolated. He offers a free fixture and checklist to help identify these issues, and also sells a comprehensive audit kit.
Cenk KURTOĞLU shares a pre-launch checklist for developers using Next.js and Supabase, highlighting key areas like secrets management, RLS, and server-side queries. The checklist is available as a paid PDF and aims to aid developers in preparing their applications for launch. Feedback is requested from the Supabase community on potential improvements or additional samples.
devcaiada's answer is the right core: in Postgres 16, a CREATEROLE user gets ADMIN OPTION on roles it creates, but **SET defaults to false** — so `postgres` can own-manage the role but can't become it until you grant SET. That's exactly why `pg_has_role(..., 'SET')` returns false. What I'd add is the **self-contained migration-file version** — everything in one transaction so it works unchanged in CI (Supabase CLI `db push` / `migrate`), not just the SQL Editor: ```sql BEGIN; GRANT onefix_recruitment_api_owner TO postgres WITH SET TRUE, INHERIT FALSE; SET LOCAL ROLE onefix_recruitment_api_owner; ALTER TABLE onefix_recruitment.applications ADD COLUMN col_1 text, ADD COLUMN col_2 text, ADD COLUMN col_3 text; CREATE OR REPLACE FUNCTION onefix_recruitment.your_function() LANGUAGE plpgsql SET search_path = '' AS $$ BEGIN -- updated logic END; $$; RESET ROLE; REVOKE onefix_recruitment_api_owner FROM postgres; COMMIT; ``` Three gotchas that bite people with this owner-role pattern: 1. **`SET LOCAL ROLE` must be inside the transaction** — it resets at COMMIT/ROLLBACK automatically, which is what you want for CI. Plain `SET ROLE` leaks to the rest of the session. 2. **`CREATE OR REPLACE` on the SECURITY DEFINER function**: the session role must own the *function* too, not just the table — check both `select tableowner from pg_tables where tablename='applications'` and `select proowner::regrole from pg_proc where proname='your_function'` before replacing, or you get the same 42501 one step later. 3. **Always pin `search_path` when (re)creating SECURITY DEFINER functions** — an unpinned definer function is a search_path hijack risk, and ownership migrations are exactly when this gets forgotten. One check before the grant: make sure `postgres` actually has ADMIN on the owner role (`select pg_has_role('postgres', 'onefix_recruitment_api_owner', 'MEMBER')`) — if a different role created it, that creator or `supabase_admin` needs to run the grant. I wrote up this whole failure class — dedicated owner roles that quietly break migrations and definer functions — in ["You Gave Your SECURITY DEFINER Function a Dedicated Owner Role. It Broke."](https://dev.to/cekuu35/you-gave-your-security-definer-function-a-dedicated-owner-role-it-broke-4cg9), and there's a pre-flight checklist for exactly these 42501/ownership cases in the [Supabase Migration Rescue Kit](https://cengokurtoglu.gumroad.com/l/tzkggl) (status-check query, repair playbook, pre-deploy checklist).
NXDOMAIN on the main API hostname after a pause/resume is a DNS-propagation issue — the dashboard "Healthy" check confirms the database is running, but the DNS record for `<project-ref>.supabase.co` may not have propagated back to your resolver yet. Three checks in order: **1. Confirm the record actually exists (bypass your local resolver):** ``` nslookup svgiidgrfsuzbkzhvfmx.supabase.co 8.8.8.8 nslookup svgiidgrfsuzbkzhvfmx.supabase.co 1.1.1.1 ``` If Google's or Cloudflare's resolvers return an A record but your default resolver still says NXDOMAIN, it's a negative-cache issue on your side — the NXDOMAIN answer got cached locally. Flush it: - Windows: `ipconfig /flushdns` - macOS: `sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder` - Linux (systemd-resolved): `sudo resolvectl flush-caches` **2. If public resolvers also return NXDOMAIN**, the DNS record genuinely hasn't been re-published after the resume. This happens when the re-provisioning is still completing internally — the "Healthy" badge means the Postgres core is up; the edge/DNS layer can trail it. In that case, this is squarely in support's court (your ticket SU-507251 is the right move), and it's worth noting to them that *both* public resolvers return NXDOMAIN — that's your evidence it's platform-side, not local. **3. The test that proves which side you're on:** ``` curl -s -o /dev/null -w "%{http_code}" https://svgiidgrfsuzbkzhvfmx.supabase.co/rest/v1/ ``` - HTTP 401 = the API is reachable (just needs auth) — DNS is fine, the problem was local cache - Connection error = DNS genuinely not propagated yet For anyone's future pause/resume cycles: the practical expectation on the Free plan is that full re-provisioning (database *plus* DNS *plus* the pooler's tenant registration) can take several minutes after the dashboard shows green, and stale negative DNS answers on the client side extend the outage well past the platform's actual recovery time. For the pooler-side variant of this (Supavisor returning ENOTFOUND after resume), the adjacent thread has that half: https://github.com/supabase/supabase/discussions/51649#discussioncomment-18919717
A project-wide ENOTFOUND (both pooler modes, standard connection details copied from the dashboard) is a different situation from the custom-role version of this error — it usually means the pooler's tenant registration for your project is out of sync rather than anything wrong with your connection string. Three checks worth running in order: **1. Verify the hostname prefix explicitly.** The most common cause of this error across regions is the `aws-0` vs `aws-1` prefix. Copy it *character by character* from Dashboard → Connect — not from memory or an older note. The correct format for Mumbai should be `aws-0-ap-south-1.pooler.supabase.com` (or `aws-1-...` if your project is on the newer edge). A quick sanity check from the same machine: ``` nslookup <pooler-hostname> ``` If it resolves, the connection reaches Supavisor and the ENOTFOUND is a *tenant lookup* (registration issue). If it doesn't resolve, it's DNS — which means a different problem, not a Supavisor one. **2. Recent pause/resume?** Free-tier projects pause after inactivity and re-provision on resume. The Postgres core can report Healthy while Supavisor's tenant registration is still catching up — the dashboard "Healthy" checks the database, not necessarily the pooler's routing table. If the project was paused recently: wait 10–15 minutes and retry, before anything else. The CLI `link` succeeding tells you the management API is up; it doesn't validate the pooler's tenant state. **3. The definitive test that isolates the pooler entirely:** ``` psql "postgresql://postgres:<password>@db.<project-ref>.supabase.co:5432/postgres" -c "select 1" ``` That's the direct connection — it bypasses Supavisor completely. On the Free plan it resolves IPv6-only, so run it from a host with IPv6 (or note the failure separately). If direct works and both pooler modes give ENOTFOUND, the connection details are fine and the tenant registration needs the support-side refresh — which is exactly the evidence that makes a support ticket resolve fast. **The CLI dump failing is expected in this state** — it uses the same pooler route for its connection. Your backups will start working the moment the pooler tenant resolves; nothing separate to fix there. For the deeper version of this troubleshooting (custom roles through the pooler, the user-registry sync behavior, and the pg_dump compatibility notes), the adjacent thread has the full map: https://github.com/supabase/supabase/discussions/51587#discussioncomment-18909084
Short answer: **on Supabase, RLS is the standard and the only policy layer that runs inside the database itself — but it's not the only security method, and some tables genuinely don't need it.** The decision per table: **RLS is necessary when the table is reachable by the API keys that ship in your frontend bundle.** The `anon` and `authenticated` roles can query every table in the `public` schema through the auto-generated REST API. If those roles have grants on a table and RLS is off, anyone holding your anon key can read (and potentially write) every row. That's why "every table a client can reach gets RLS" is the practical rule, not a style preference. **Tables that don't need RLS:** - Tables only written by the server (via a `SECURITY DEFINER` function or a server-side connection using the service role) and never queried as the requesting user. The service role bypasses RLS anyway, so RLS on those tables protects nothing the server does — it's defense-in-depth at best. - Reference data that's intentionally public (countries, categories, feature flags): RLS with a permissive `for select to authenticated` or even to anon is still worth enabling — RLS on with a permissive policy is deliberate; RLS off is an accident waiting for a new column. **The alternatives to RLS (and why they complement, not replace it):** 1. **API-only access:** revoke all grants from `anon`/`authenticated` on a table and route every read/write through a `SECURITY DEFINER` function that does its own checks. Works, but you've moved the policy logic out of the database and into function bodies — harder to audit, easy to drift. 2. **Column-level grants (Postgres 10+):** grant `SELECT(name, avatar_url)` instead of full-table reads for sensitive tables (e.g., `profiles` with an email column). RLS decides *which rows*, column grants decide *which columns* — they compose. 3. **Views as the public surface:** expose a narrow view to the API roles and revoke the base table. `security_invoker` views (PG15+) still run through the base table's RLS; `security_definer` views don't — know which one you're writing. If your app is already done and some tables missed RLS, the highest-value order: (1) list which tables `anon`/`authenticated` can touch (`select * from information_schema.role_table_grants where grantee in ('anon','authenticated') and table_schema='public'`), (2) enable RLS on those that hold user data, (3) add one policy per operation your app performs on each. The policy class that quietly leaks data is `to authenticated using (true)` — "any logged-in user can read every row" — worth grepping for once RLS is on: ```sql select tablename, policyname, roles from pg_policies where schemaname = 'public' and roles::text in ('{anon}', '{public}', '{authenticated}') and qual = 'true'; ```
Solid scope — the 16-check single-SELECT approach is the right shape for this (zero-install catalog reads are what people actually run). Feedback on the heuristic classes from scanning 100+ public Supabase repos for exactly these patterns: **The highest false-positive-rate class: "signed in treated as ownership."** The dominant shape in the wild is `for select to authenticated using (true)` — which your check flags, correctly — but the same row also catches two legitimate patterns: reference/catalog tables (countries, plan tiers, feature flags) where authenticated-wide reads are intentional, and lookup tables where a second ownership policy narrows the row set. The distinguishing signal worth adding: flag it differently when the table *also* has an ownership policy on the same cmd (a deliberate two-policy design) vs. when `using (true)` is the only policy. **Views: the PG15+ distinction matters.** `security_invoker` views (and `security_invoker = true` functions in PG15+) actually *respect* RLS — they run as the caller. Only `security_definer` views bypass it. If your check flags all views the API can reach, every `security_invoker` view will be a false positive on hosted Supabase where PG17 is the default. The catalog signal: `pg_class.relowner` + the reloptions, or check `has_column_privilege` under the invoker assumption. **Column-level grants add a layer the write-policy checks can't see.** Postgres supports per-column grants: a policy may allow `for update using (auth.uid() = user_id)` on the *row*, while `information_schema.column_privileges` shows `update` is only granted on (name, avatar_url) — meaning the plan column is blocked at the grant layer even though the policy looks wide. The inverse misfire: a policy that looks safe (ownership-scoped) with `grant update(plan) to authenticated` at the column level. Worth adding a `column_privileges` check for the sensitive-column list (plan, role, is_admin). **The user_metadata class:** the heuristic that saves the most debugging is distinguishing metadata as the *sole* authorization (dangerous) vs. metadata in *combination with* ownership (usually fine — e.g., `auth.uid() = user_id AND (raw_user_meta_data()->>'role') = 'staff'`). The sole-authorization shape is the one that dies the first time someone edits their metadata client-side. **One addition for the storage checks:** public buckets without an owner check are worth flagging, but the same check should look at `storage.objects` policies that use `bucket_id = X` as the only predicate — bucket-scoped without ownership is the storage-side equivalent of the `using (true)` class and shows up in AI-generated apps. For calibration data if useful: in the public-repo scans, the roughly-sorted prevalence is permissive `to authenticated` policies (~25% of repos) > world-readable `using (true)` with no TO (~15%) > committed credentials (~10%) > SECURITY DEFINER functions without search_path pinning (~8%). The fixed-search_path class is small but the highest severity per instance. Good call publishing the test database next to it — the reproducibility is what makes false-positive feedback actionable. Following the script's development.
This is a big one for the October 30 change specifically — grants and policies becoming first-class generated objects means the exact thing people are scrambling to audit manually (which grants exist, which policies scope them) is now declarative and diffable. Two questions on scope: **1. Does pg-delta diff `ALTER DEFAULT PRIVILEGES` state?** After October 30, the default-privilege entries on a project decide what new tables inherit — if the declarative files can capture and generate those too, that's the cleanest version-controlled Oct 30 prep available (the "what will my *next* table get" question becomes reviewable in a PR instead of a `pg_default_acl` query). **2. How does the grants diff treat the platform-managed grants on `storage`/`auth` schemas?** Presumably pg-delta diffs the schemas you declare, and managed schemas are out of scope — but worth stating explicitly for anyone hoping this also reconciles drift on managed objects (it's the #1 confusion in the grants-audit threads). Also: the "review it" step in the workflow is doing a lot of quiet load-bearing work, and it's the step people skip. The failure mode we keep seeing in the wild is a generated migration that's *correct* but unread — someone reviews the first 20 lines of a 200-line grants diff and ships it. If the CLI could emit a summary header in the generated file ("+12 grants, -3 policies, 2 tables, 0 default-privilege changes"), review quality would go up the same way test summaries did.
Short answer: **yes for table-returning RPCs, no for scalar/JSON returns — and the function's own `LIMIT` is the one that wins if both exist.** The longer version, because the layers matter: **1. PostgREST's `db-max-rows` applies to collection responses.** If your function is declared `returns setof <table>` (or `table(...)`) and you call it via `.rpc()`, PostgREST treats the response as a collection — the same row limit that applies to direct table reads applies to the RPC result. So `max-rows: 1000` truncates an RPC returning 5,000 rows the same way it truncates a table query. **2. Scalar and JSON returns sidestep it.** If the function returns a single row type or `json`/`jsonb` (a common pattern for paginated or aggregated RPCs), the response is one document — there's no collection for PostgREST to limit. A function that manually builds and returns a `jsonb` array can return any size, regardless of `db-max-rows`. **3. The function's own LIMIT is authoritative at the SQL layer.** If the function body contains an explicit `limit N` (or `select ... limit` inside `return query`), that runs inside Postgres — PostgREST's limit then applies to whatever the function already returned. They compose: `min(function's LIMIT, PostgREST's max-rows)` is effectively what the client sees. **4. RLS interacts, SECURITY DEFINER doesn't.** For an `security invoker` function, RLS on the underlying tables filters what the function can see — so the limit applies to *post-RLS* rows. For `security definer`, RLS is bypassed (the owner's privileges), and the limit applies to the unfiltered result — worth knowing when a definer function with no internal LIMIT can return far more than a table query ever would. Practical pattern when you need guaranteed bounds on an RPC: ```sql create or replace function public.get_recent_items(p_limit int default 50) returns setof public.items language sql security invoker as $$ select * from public.items order by created_at desc limit greatest(1, least(p_limit, 500)); -- server-side clamp $$; ``` The server-side clamp means the client can never request unbounded results even if `db-max-rows` is set generously or the caller passes `p_limit => 999999`.
Adding three angles to @saraeloop's diagnosis, because the combination of **custom role + shared pooler + Free plan** has specific failure pockets worth separating: **1. The pooler's user registry is a separate store, and it syncs on its own schedule.** Supavisor doesn't read `pg_roles` live — it maintains its own tenant/user mapping, and a role created via the SQL editor doesn't appear in it instantly. If `personal_os_backup_reader` was created recently, retrying the same connection a few hours later is genuinely informative (and worth doing before the support ticket — "worked after N hours" vs "still failing after N days" changes the ticket entirely). Pause/resume doesn't force this registry to re-sync (as you observed), and there is no self-serve refresh — your question 2's supported answer is the support ticket, which is the right next move given everything you've already ruled out. **2. The direct-connection fallback has an IPv6 trap on the Free plan.** Before concluding the pooler is the only path: `db.<project-ref>.supabase.co:5432` (direct) accepts your custom role with plain `personal_os_backup_reader` as the username (no `.ref` suffix — that's pooler-only syntax) and the role's own password. But on the Free plan the direct hostname is **IPv6-only** — if your backup host (a GitHub Actions runner, most cloud VMs, most home networks) is IPv4-only, the connection fails at DNS/connect, which *looks* like the same class of problem. Worth one explicit test from your backup host: ``` nslookup db.<project-ref>.supabase.co ``` AAAA records only → the direct path is closed from that host without the IPv4 add-on, and the pooler is genuinely your only route — which strengthens the support case rather than weakening it. **3. The backup-architecture note: pg_dump and the pooler don't mix well anyway.** For your actual goal (least-privilege automated logical backups), one compatibility point worth knowing before you invest more in the custom-role-via-pooler route: `pg_dump` over a **transaction** pooler (6543) fails on prepared-statement/locking behavior, and while the **session** pooler (5432, your current attempt) works for ordinary sessions, some dump/restore tooling misbehaves behind *any* pooled connection. If the support ticket resolves the mapping, test a full `pg_dump --schema=public` through it before trusting the pipeline — the connection establishing is necessary but not sufficient evidence that the dump completes cleanly. The `$0` least-privilege-adjacent alternative most people land on for Free-tier backups: a scheduled `supabase db dump` via the CLI in a private GitHub Actions runner, authenticated with the project's `SUPABASE_ACCESS_TOKEN` (not the custom role — it runs as postgres). It sacrifices the role-scoping you want, so it's a fallback, not an equivalent — but it unblocks production backups while the ticket resolves the pooler mapping. Your least-privilege design is the right target; the two constraints (Free plan + custom role through shared Supavisor) are just fighting each other in a way only support can fix.
Adding the effective-risk layer to your (thorough) catalog work, because one fact in your post deserves a flag before anyone treats the enabled RLS as the mitigation: **TRUNCATE is not subject to row-level security in Postgres.** RLS filters SELECT/INSERT/UPDATE/DELETE — TRUNCATE bypasses it by design. So the residual TRUNCATE grants on `storage.buckets`/`storage.objects`/`storage.buckets_analytics` are *not* mitigated by the RLS you confirmed; they're only mitigated by whether the grant can actually be *exercised*. ## Who can exercise these grants on hosted Supabase 1. **PostgREST path: no.** The REST layer exposes row-level operations; there is no `TRUNCATE` operation in the PostgREST surface. The anon/authenticated roles reach Postgres through PostgREST, and this vector doesn't exist there. 2. **Realtime: no.** Realtime reads WAL for change delivery; no DDL pathway. 3. **Direct Postgres connections: effectively no, with one caveat.** The `anon`/`authenticated` roles are JWT-substituted roles — they hold no passwords, so they can't log in directly. Direct connections use password-holding roles (`postgres` or custom LOGIN roles), which exercise their own grants, not these. The caveat: any custom SECURITY DEFINER function in *your* project (not the managed storage schema) that accepts a table name and issues dynamic SQL while granted to the API roles would be a bridge — a quick census for that class: ```sql select p.proname, n.nspname, pg_get_userbyid(p.proowner) as owner from pg_proc p join pg_namespace n on n.oid = p.pronamespace join pg_roles r on r.oid = p.proacl::regrole where p.prosecdef and n.nspname = 'public' and exists ( select 1 from aclexpansion(acl) -- or your preferred ACL expansion ); ``` (The practical version: list your own SECURITY DEFINER functions in `public` and confirm none of them execute DDL from role-controllable input. In a stock project the set is small — auth triggers, `handle_new_user` and friends — and none do.) 4. **Dashboard SQL editor: runs as `postgres`**, which isn't gated by these grants at all. So the effective exercise surface today is: **none through the supported paths, gated only by future changes** — which is exactly why you're right to want the provider-supported rollout rather than a workaround. ## What migration 0073 changes (from the merged storage PR) The tenant migration addresses precisely the three tables you listed (plus vector-permission and default-privilege cleanup). Two things worth noting for your recovery procedure: - **Rollout asymmetry:** the platform applies managed migrations on its schedule; your project's effective grants may retain the residual state until the platform's rollout reaches your tenant — the catalog state you see *today* is legitimate to observe until then. Your read-only approach (inspect, don't touch) is the correct posture for staying supported. - **Recovery:** the supported recovery for a mistaken manual change on managed objects has been "support ticket + platform-side reconciliation" (the managed-schema discussion you linked documents the restricted operations explicitly). There is no self-serve re-run for the tenant migration on hosted — by design. ## The interim verification set While waiting for the supported rollout, the checks that keep the file clean: ```sql -- 1. Effective grants haven't grown (the census you already run) select grantee, table_name, privilege_type from information_schema.role_table_grants where table_schema = 'storage' and grantee in ('anon', 'authenticated') order by table_name, privilege_type; -- 2. No SECURITY DEFINER bridge in your own schema (the class above) select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.prosecdef; -- 3. Default privileges for future storage objects haven't drifted select * from pg_default_acl where defaclnamespace = 'storage'::regnamespace::oid; ``` Baseline all three today, re-run after the platform announces the rollout — any drift between the two runs is your support-ticket evidence, which is a materially stronger artifact than a one-time snapshot. For the credential-side hygiene questions that pair with this managed-surface audit (what the rotation path records where, and which channels can't guarantee redaction) — the adjacent threads have the channel-by-channel map: #51544 and #51499.
The most common cause after a database password reset on the newer poolers is the **username format**, not the password itself: the Supavisor connection requires the project-ref-qualified username, not plain `postgres`. For your project, the session-pooler connection should be: ``` postgresql://postgres.ivfakupzejzzsekgwnrq:[NEW-PASSWORD]@aws-0-<region>.pooler.supabase.com:5432/postgres ``` Note `postgres.ivfakupzejzzsekgwnrq` — the `.ivfakupzejzzsekgwnrq` suffix is required on the pooler (both 5432 session and 6543 transaction). A connection string that worked before with plain `postgres` against the direct connection will fail with exactly your error (`password authentication failed for user "postgres"`) the moment you route it through the pooler, because the pooler routes by the username suffix and falls back to legacy auth when it's missing. **Verification order after any password reset:** 1. **Direct first** (bypasses the pooler entirely): `postgresql://postgres:[NEW-PASSWORD]@db.ivfakupzejzzsekgwnrq.supabase.co:5432/postgres` — plain `postgres` is correct here. If this fails, the reset didn't take or the password has a character that needs URL-encoding in the string. 2. **Then the pooler**: same password, but username `postgres.ivfakupzejzzsekgwnrq`. If direct works and the pooler still fails right after a reset, the session pooler is holding cached auth for your old verifier — pooler sessions recycle on their own within minutes, or disconnect everything (dashboard → Connections shows active sessions) to force it. **On special characters in the new password:** the reset accepts them, but the connection string needs them percent-encoded (`@` → `%40`, `:` → `%3A`, `/` → `%2F`, spaces → `%20`). A mismatch there produces the same FATAL while the password is actually correct — worth checking before anything else. If the pooler authenticates but your app still fails, the app's cached string is the culprit (restart after rotating the env var). For what's recorded in logs during this (statement logs capture the ALTER ROLE with the new verifier when you reset via the dashboard, and the pooler's own diagnostics are separate) — the credential-lifecycle details are in the adjacent threads: https://github.com/supabase/supabase/discussions/51544 and https://github.com/supabase/supabase/discussions/51499
You're right on both, and the live test is the convincing part — I'll stop claiming those two. The with-check-omission case: since Postgres applies the USING rule to the new row when WITH CHECK is absent, it fails loudly, not silently. And insert-then-select with no select policy errors rather than returning empty — I verified against a live project. That narrows the true silent class to exactly what your finder targets: **no matching policy at all → zero rows affected, no error anywhere.** Thanks for testing against real Postgres instead of arguing from docs — the repo shipping a live test is better evidence than my reasoning was. Following supabase-wrote now.
Fair corrections on both — you're right, and I re-tested the mechanics rather than argue: a TO-less policy with an auth.uid() predicate does evaluate for anon, but auth.uid() is NULL in that context, so the predicate fails and anon sees nothing. The class I should have scoped to is TO-less **plus a predicate that passes for anon** — `using (true)`, `is_public` flags — which is the actual leak shape I keep finding in real audits (policies *named* "Users can view X" written as `using (true)` with no TO: a genuine leak, but for a different reason than I stated). So my pg_policies query catches two populations and only one is an exposure: the anon-passing subset are leaks; the auth.uid()-with-no-TO subset are hygiene. Good distinction to keep sharp — and point taken that it's policy-side rather than grants. Keeping grants-check as a grants tool is the cleaner scope.
Following this from the adjacent thread (#51499) since it's the same credential-lifecycle family — your questions here go deeper into the logging side, and the distinctions you're drawing (pre-storage vs. dashboard redaction) are the right ones to insist on for a compliance posture. Here's the mechanics as accurately as I can state them: **1. Supported procedure for creating/rotating a custom LOGIN role** The platform-supported path is plain SQL as `postgres` (which holds CREATEROLE on hosted): `CREATE ROLE app_runtime LOGIN PASSWORD '...'` issued from the SQL editor or via `supabase db ...` from the CLI. For rotation with no shared state, the brief-dual-role pattern from #51499 applies (create `app_runtime_v2`, redeploy with the new connection string, drop the old role after drain). For a *written* security statement applicable to your project configuration (your Q6), that genuinely needs a support ticket — what the community can give you is mechanics; the attestation is a commercial/support artifact. **2. Where the password/verifier can and cannot appear, per channel** - **`pg_stat_statements`: safe by construction, and the protection is pre-storage.** Query text is normalized at capture time — literals become `$n` before anything is stored. An `ALTER ROLE ... PASSWORD 'SCRAM-SHA-256$...'` is stored as `ALTER ROLE ... PASSWORD $1`. This is the strongest guarantee in the stack: normalization happens before storage, not redaction after. - **Postgres statement/error logs:** capture by *policy*, not universally. Supabase's default Postgres logging does not record every statement; error logs capture failing statements. If a rotation is performed and the statement errors (typo, invalid password policy), the failing statement text — including the verifier literal — can land in the error log. So: perform rotation in a session where the statement succeeds, and treat error-log retention windows as in-scope for your threat model. - **pgAudit:** this is the channel you should assume *does* capture it if DDL logging is enabled. pgAudit logs statement text for the classes you enable; `ALTER ROLE` with an embedded verifier will appear if ROLE/DDL classes are on. Protection here is *procedural*: rotate via a role/session that pgAudit doesn't audit (pgAudit can be scoped by role), or accept that the verifier appears in your own audit trail (it's a hash, not the password — but your stated requirement excludes verifiers too, so scope pgAudit accordingly). - **Supavisor:** the pooler handles authentication exchanges; it is not a statement-logging surface and does not persist auth material in its diagnostics. The caveat from the other thread stands: transaction mode means no session-level state, but that's orthogonal to logging. - **Platform/internal logs (dashboard SQL editor history, etc.):** statements submitted through the dashboard are subject to the platform's own telemetry; assume the statement text *may* be retained there, and prefer the CLI/psql for rotation if that matters. The dashboard's display redaction (if any) is post-storage — your instinct is correct that it doesn't count as a control. **3. Pre-storage vs. redaction — the honest map** | Channel | Protection type | |---|---| | pg_stat_statements | Normalization at capture (pre-storage) — safe | | Postgres error logs | None for the failing statement text — rotate carefully | | pgAudit (if DDL on) | None — procedural scoping required | | Dashboard telemetry | Assume retained; display redaction is post-storage | | Migration files in git | None — the classic leak vector; never commit rotation | **4. The guaranteed-clean path** If your requirement is "no plaintext and no verifier in *any* hosted log," the supported procedure that achieves it: rotate from a psql session using `\password` (the verifier is computed client-side and the wire statement is the `ALTER ROLE` — same as any client, but your local shell history and any local logging are under *your* control), or use the CLI's connection flow. Then keep rotation *out of* migration files, dashboards, and audited sessions. **5. Temporary database access (your Q5)** The temporary-access flow issues short-lived credentials through the platform; the token itself and the exchange material are managed by Supabase's auth plane rather than appearing as role passwords in Postgres logs. The equivalent protections: short TTL (expiry is the rotation), no SCRAM verifier in the Postgres catalog to leak, and the auth exchange happening outside statement-logging surfaces. For staging workloads where a standing LOGIN role isn't strictly required, temporary access is arguably the *better* answer to your requirement — it removes the persistent-credential lifecycle your whole question set is about. One caveat on all of the above: channel behavior is configuration-dependent (pgAudit classes, log retention settings, plan tier). For an auditable statement of *your project's* actual configuration, the support ticket in Q6 is the right instrument — worth requesting with your specific pgAudit/retention settings attached so the statement is precise rather than generic.
This is the right pre-flight for the date, and the service_role catch (your own backend, not just client reads) is the one that actually surprises people — most "Oct 30" write-ups only cover anon. One complementary check that pairs with it: the *policy*-side of the same change. Your file covers grants (who can reach the table); the other half is whether the policies those grants unlock are actually scoped. The class I find most in the wild: policies with a correct-looking predicate but **no `TO` clause** — Postgres evaluates those for PUBLIC, so anon gets in even though the name says "Users can view X": ```sql select tablename, policyname, roles from pg_policies where schemaname = 'public' and roles::text = '{public}'; ``` Every row is a policy the anon key can hit. Pairing your grants-check with this gives the full picture — grants answer "can they reach it," policies answer "what do they get once they do." (Policy side written up here, with the self-healing pattern for tables created after the date: https://dev.to/cekuu35/supabases-default-privileges-expose-every-new-table-heres-the-self-healing-fix-43jn ) Two additions worth folding into grants-check if you extend it: 1. **`ALTER DEFAULT PRIVILEGES` state for the `postgres` role** — that's the line that actually decides what a post-Oct-30 table inherits, and it's the one people will want to keep or change. 2. **Views built on `security definer` functions** — a view can be anon-reachable even when every base table's grants are clean, which your "views the API can reach" row would catch nicely. The "changes nothing / read-only" guarantee is what makes this safe to run on production — good call shipping it that way.
Nice — this solves a real gap: `error: null` with zero rows affected is the worst failure mode in the stack, and a lint that fails CI on new silent writes is the right shape. One more silent case worth folding into `policies.sql`, because it's the sibling of your UPDATE-without-SELECT: **UPDATE with a `qual` but no `with_check`.** A policy like `for update using (auth.uid() = user_id)` (no `with check`) lets the row be *selected* for update but rejects the write — again `error: null`, zero rows. And the mirror on INSERT: `for insert with check (...)` with no `for select` policy means the insert succeeds but the row is invisible to `.select()` right after — reads as "saved but gone." The catalog query that catches both: ```sql select tablename, policyname, cmd, qual, with_check from pg_policies where schemaname = 'public' and cmd in ('UPDATE', 'INSERT') order by tablename; ``` Any UPDATE row with `qual` set and `with_check` null, and any INSERT without a matching SELECT's grant path, is a silent-write candidate. Worth cross-linking: I maintain the symptom-index side of this family (fix-by-symptom map: https://dev.to/cekuu35/rls-not-working-fix-by-symptom-a-debugging-map-for-supabase-4gj9 ) — if you ever want to point `supabase-wrote` users at a fix walkthrough, it maps each symptom to the exact policy/grant fix. Complementary tools, same problem. Good call shipping it as a lint (`npx supabase-wrote find .`) — detection at commit time is where this class actually gets killed.
Your requirements are all achievable on the Free plan — the constraint to design around is that the platform connection (pooler / SQL editor) hands you `postgres`, and on hosted Supabase `postgres` is a non-superuser with `CREATEROLE`. Here's a design that fits each of your four questions. **The core pattern: separate the *connection* from the *execution role*** You can't avoid `postgres` as the *login* on Free (that's the credential the pooler gives you), but you can avoid *executing as* postgres. Don't try — create a NOLOGIN owning role and have every migration script open with `SET ROLE`: ```sql -- one-time setup (as postgres, which has CREATEROLE) create role app_owner nologin; grant app_owner to postgres with set true; -- PG16+: membership with SET, no INHERIT -- every migration thereafter opens with: set role app_owner; -- DDL below this line executes as app_owner, not postgres ``` All schema objects (tables, functions) are created owned by `app_owner`. When `postgres` needs to intervene, it does so explicitly via `SET ROLE` — the same discipline a `sudo`-style operator would use. That answers your Q1 and Q2 simultaneously: no long-lived privileged login exists, because the privileged role cannot log in at all, and the routine executor context is the narrowly-scoped role rather than the connection identity. **Runtime separation (your requirement 4)** stays as you specified: `authenticated` (via PostgREST) gets zero direct table grants and only `EXECUTE` on narrowly-scoped `SECURITY DEFINER` functions owned by a second NOLOGIN role (e.g. `rpc_owner`), each with `search_path = ''` and `revoke ... from public, anon`. One non-obvious failure mode: the SECURITY DEFINER owner role needs its *own* explicit table grants (custom roles never inherited Supabase's defaults) — grant it exactly what the function bodies touch, or the functions fail at runtime with `permission denied for table` even though creation succeeded. (Details on that trap and the membership mechanics here: https://dev.to/cekuu35/you-gave-your-security-definer-function-a-dedicated-owner-role-it-broke-heres-why-1oj3) **Pre-migration verification (your Q3)** — three checks worth automating as a gate script that refuses to run if any fail: ```sql -- 1. execution context is the operator, not the platform login select current_user; -- must be app_owner, not postgres select current_setting('role', true); -- 2. memberships look like the design intends (PG16+ columns) select member, grantor, admin_option, set_option, inherit_option from pg_auth_members where roleid in ('app_owner'::regrole, 'rpc_owner'::regrole); -- 3. runtime role holds no direct table grants (RLS is the only path) select table_name, privilege_type from information_schema.role_table_grants where grantee = 'authenticated' and table_schema = 'public'; -- expect: zero rows ``` And one post-migration check that catches what migrations most often silently introduce — a permissive policy: ```sql select tablename, policyname, roles, qual from pg_policies where qual = 'true' and roles::text in ('{anon}','{public}'); -- expect: zero rows, or rows you can explain ``` **Supabase-specific limitations (your Q4), honestly:** 1. **`supabase_admin` re-syncs.** Membership rows granted by the platform (grantor `supabase_admin`) can be re-touched on maintenance passes. Your `grant ... with set true` on roles *you* created is yours to keep, but verify with the `pg_auth_members` check above after platform maintenance, in CI, not from memory. 2. **Default privileges work against you until Oct 30.** New tables created today auto-grant to `anon`/`authenticated` at the platform level. Your migrations should treat that as hostile: after every `create table`, either revoke or apply RLS in the same transaction. (After Supabase's October 30 change, new tables in existing projects stop auto-granting — which makes explicit `grant`s in migrations *mandatory* instead of optional. Same discipline, opposite default; worth baking in now so nothing depends on either default.) 3. **Marketplace shape:** if sellers see only their own listings, put the tenant predicate in RLS now (`organization_id`/`seller_id` column + policy), and make the admin panel authenticate as `authenticated` with a role check inside the SECURITY DEFINER functions — never a service-key path, or your `auth.uid()`-based audit trail goes NULL. 4. **Free-plan caveat:** the staging/prod separation you're doing is exactly right; keep the approval gate as a human step reading the verification query outputs — the checks above are cheap enough to paste into the approval PR description. The one-sentence version: `postgres` is your transport, a NOLOGIN owner is your actor, RLS-plus-revoked-EXECUTE is your runtime contract, and a three-query gate script is your migration bouncer.
Your requirements are all achievable on the Free plan — the constraint to design around is that the platform connection (pooler / SQL editor) hands you `postgres`, and on hosted Supabase `postgres` is a non-superuser with `CREATEROLE`. Here's a design that fits each of your four questions. **The core pattern: separate the *connection* from the *execution role*** You can't avoid `postgres` as the *login* on Free (that's the credential the pooler gives you), but you can avoid *executing as* postgres. Don't try — create a NOLOGIN owning role and have every migration script open with `SET ROLE`: ```sql -- one-time setup (as postgres, which has CREATEROLE) create role app_owner nologin; grant app_owner to postgres with set true; -- PG16+: membership with SET, no INHERIT -- every migration thereafter opens with: set role app_owner; -- DDL below this line executes as app_owner, not postgres ``` All schema objects (tables, functions) are created owned by `app_owner`. When `postgres` needs to intervene, it does so explicitly via `SET ROLE` — the same discipline a `sudo`-style operator would use. That answers your Q1 and Q2 simultaneously: no long-lived privileged login exists, because the privileged role cannot log in at all, and the routine executor context is the narrowly-scoped role rather than the connection identity. **Runtime separation (your requirement 4)** stays as you specified: `authenticated` (via PostgREST) gets zero direct table grants and only `EXECUTE` on narrowly-scoped `SECURITY DEFINER` functions owned by a second NOLOGIN role (e.g. `rpc_owner`), each with `search_path = ''` and `revoke ... from public, anon`. One non-obvious failure mode: the SECURITY DEFINER owner role needs its *own* explicit table grants (custom roles never inherited Supabase's defaults) — grant it exactly what the function bodies touch, or the functions fail at runtime with `permission denied for table` even though creation succeeded. (Details on that trap and the membership mechanics here: https://dev.to/cekuu35/you-gave-your-security-definer-function-a-dedicated-owner-role-it-broke-heres-why-1oj3) **Pre-migration verification (your Q3)** — three checks worth automating as a gate script that refuses to run if any fail: ```sql -- 1. execution context is the operator, not the platform login select current_user; -- must be app_owner, not postgres select current_setting('role', true); -- 2. memberships look like the design intends (PG16+ columns) select member, grantor, admin_option, set_option, inherit_option from pg_auth_members where roleid in ('app_owner'::regrole, 'rpc_owner'::regrole); -- 3. runtime role holds no direct table grants (RLS is the only path) select table_name, privilege_type from information_schema.role_table_grants where grantee = 'authenticated' and table_schema = 'public'; -- expect: zero rows ``` And one post-migration check that catches what migrations most often silently introduce — a permissive policy: ```sql select tablename, policyname, roles, qual from pg_policies where qual = 'true' and roles::text in ('{anon}','{public}'); -- expect: zero rows, or rows you can explain ``` **Supabase-specific limitations (your Q4), honestly:** 1. **`supabase_admin` re-syncs.** Membership rows granted by the platform (grantor `supabase_admin`) can be re-touched on maintenance passes. Your `grant ... with set true` on roles *you* created is yours to keep, but verify with the `pg_auth_members` check above after platform maintenance, in CI, not from memory. 2. **Default privileges work against you until Oct 30.** New tables created today auto-grant to `anon`/`authenticated` at the platform level. Your migrations should treat that as hostile: after every `create table`, either revoke or apply RLS in the same transaction. (After Supabase's October 30 change, new tables in existing projects stop auto-granting — which makes explicit `grant`s in migrations *mandatory* instead of optional. Same discipline, opposite default; worth baking in now so nothing depends on either default.) 3. **Marketplace shape:** if sellers see only their own listings, put the tenant predicate in RLS now (`organization_id`/`seller_id` column + policy), and make the admin panel authenticate as `authenticated` with a role check inside the SECURITY DEFINER functions — never a service-key path, or your `auth.uid()`-based audit trail goes NULL. 4. **Free-plan caveat:** the staging/prod separation you're doing is exactly right; keep the approval gate as a human step reading the verification query outputs — the checks above are cheap enough to paste into the approval PR description. The one-sentence version: `postgres` is your transport, a NOLOGIN owner is your actor, RLS-plus-revoked-EXECUTE is your runtime contract, and a three-query gate script is your migration bouncer.
Your requirements are all achievable on the Free plan — the constraint to design around is that the platform connection (pooler / SQL editor) hands you `postgres`, and on hosted Supabase `postgres` is a non-superuser with `CREATEROLE`. Here's a design that fits each of your four questions. **The core pattern: separate the *connection* from the *execution role*** You can't avoid `postgres` as the *login* on Free (that's the credential the pooler gives you), but you can avoid *executing as* postgres. Don't try — create a NOLOGIN owning role and have every migration script open with `SET ROLE`: ```sql -- one-time setup (as postgres, which has CREATEROLE) create role app_owner nologin; grant app_owner to postgres with set true; -- PG16+: membership with SET, no INHERIT -- every migration thereafter opens with: set role app_owner; -- DDL below this line executes as app_owner, not postgres ``` All schema objects (tables, functions) are created owned by `app_owner`. When `postgres` needs to intervene, it does so explicitly via `SET ROLE` — the same discipline a `sudo`-style operator would use. That answers your Q1 and Q2 simultaneously: no long-lived privileged login exists, because the privileged role cannot log in at all, and the routine executor context is the narrowly-scoped role rather than the connection identity. **Runtime separation (your requirement 4)** stays as you specified: `authenticated` (via PostgREST) gets zero direct table grants and only `EXECUTE` on narrowly-scoped `SECURITY DEFINER` functions owned by a second NOLOGIN role (e.g. `rpc_owner`), each with `search_path = ''` and `revoke ... from public, anon`. One non-obvious failure mode: the SECURITY DEFINER owner role needs its *own* explicit table grants (custom roles never inherited Supabase's defaults) — grant it exactly what the function bodies touch, or the functions fail at runtime with `permission denied for table` even though creation succeeded. (Details on that trap and the membership mechanics here: https://dev.to/cekuu35/you-gave-your-security-definer-function-a-dedicated-owner-role-it-broke-heres-why-1oj3) **Pre-migration verification (your Q3)** — three checks worth automating as a gate script that refuses to run if any fail: ```sql -- 1. execution context is the operator, not the platform login select current_user; -- must be app_owner, not postgres select current_setting('role', true); -- 2. memberships look like the design intends (PG16+ columns) select member, grantor, admin_option, set_option, inherit_option from pg_auth_members where roleid in ('app_owner'::regrole, 'rpc_owner'::regrole); -- 3. runtime role holds no direct table grants (RLS is the only path) select table_name, privilege_type from information_schema.role_table_grants where grantee = 'authenticated' and table_schema = 'public'; -- expect: zero rows ``` And one post-migration check that catches what migrations most often silently introduce — a permissive policy: ```sql select tablename, policyname, roles, qual from pg_policies where qual = 'true' and roles::text in ('{anon}','{public}'); -- expect: zero rows, or rows you can explain ``` **Supabase-specific limitations (your Q4), honestly:** 1. **`supabase_admin` re-syncs.** Membership rows granted by the platform (grantor `supabase_admin`) can be re-touched on maintenance passes. Your `grant ... with set true` on roles *you* created is yours to keep, but verify with the `pg_auth_members` check above after platform maintenance, in CI, not from memory. 2. **Default privileges work against you until Oct 30.** New tables created today auto-grant to `anon`/`authenticated` at the platform level. Your migrations should treat that as hostile: after every `create table`, either revoke or apply RLS in the same transaction. (After Supabase's October 30 change, new tables in existing projects stop auto-granting — which makes explicit `grant`s in migrations *mandatory* instead of optional. Same discipline, opposite default; worth baking in now so nothing depends on either default.) 3. **Marketplace shape:** if sellers see only their own listings, put the tenant predicate in RLS now (`organization_id`/`seller_id` column + policy), and make the admin panel authenticate as `authenticated` with a role check inside the SECURITY DEFINER functions — never a service-key path, or your `auth.uid()`-based audit trail goes NULL. 4. **Free-plan caveat:** the staging/prod separation you're doing is exactly right; keep the approval gate as a human step reading the verification query outputs — the checks above are cheap enough to paste into the approval PR description. The one-sentence version: `postgres` is your transport, a NOLOGIN owner is your actor, RLS-plus-revoked-EXECUTE is your runtime contract, and a three-query gate script is your migration bouncer.
Your requirements are all achievable on the Free plan — the constraint to design around is that the platform connection (pooler / SQL editor) hands you `postgres`, and on hosted Supabase `postgres` is a non-superuser with `CREATEROLE`. Here's a design that fits each of your four questions. **The core pattern: separate the *connection* from the *execution role*** You can't avoid `postgres` as the *login* on Free (that's the credential the pooler gives you), but you can avoid *executing as* postgres. Don't try — create a NOLOGIN owning role and have every migration script open with `SET ROLE`: ```sql -- one-time setup (as postgres, which has CREATEROLE) create role app_owner nologin; grant app_owner to postgres with set true; -- PG16+: membership with SET, no INHERIT -- every migration thereafter opens with: set role app_owner; -- DDL below this line executes as app_owner, not postgres ``` All schema objects (tables, functions) are created owned by `app_owner`. When `postgres` needs to intervene, it does so explicitly via `SET ROLE` — the same discipline a `sudo`-style operator would use. That answers your Q1 and Q2 simultaneously: no long-lived privileged login exists, because the privileged role cannot log in at all, and the routine executor context is the narrowly-scoped role rather than the connection identity. **Runtime separation (your requirement 4)** stays as you specified: `authenticated` (via PostgREST) gets zero direct table grants and only `EXECUTE` on narrowly-scoped `SECURITY DEFINER` functions owned by a second NOLOGIN role (e.g. `rpc_owner`), each with `search_path = ''` and `revoke ... from public, anon`. One non-obvious failure mode: the SECURITY DEFINER owner role needs its *own* explicit table grants (custom roles never inherited Supabase's defaults) — grant it exactly what the function bodies touch, or the functions fail at runtime with `permission denied for table` even though creation succeeded. (Details on that trap and the membership mechanics here: https://dev.to/cekuu35/you-gave-your-security-definer-function-a-dedicated-owner-role-it-broke-heres-why-1oj3) **Pre-migration verification (your Q3)** — three checks worth automating as a gate script that refuses to run if any fail: ```sql -- 1. execution context is the operator, not the platform login select current_user; -- must be app_owner, not postgres select current_setting('role', true); -- 2. memberships look like the design intends (PG16+ columns) select member, grantor, admin_option, set_option, inherit_option from pg_auth_members where roleid in ('app_owner'::regrole, 'rpc_owner'::regrole); -- 3. runtime role holds no direct table grants (RLS is the only path) select table_name, privilege_type from information_schema.role_table_grants where grantee = 'authenticated' and table_schema = 'public'; -- expect: zero rows ``` And one post-migration check that catches what migrations most often silently introduce — a permissive policy: ```sql select tablename, policyname, roles, qual from pg_policies where qual = 'true' and roles::text in ('{anon}','{public}'); -- expect: zero rows, or rows you can explain ``` **Supabase-specific limitations (your Q4), honestly:** 1. **`supabase_admin` re-syncs.** Membership rows granted by the platform (grantor `supabase_admin`) can be re-touched on maintenance passes. Your `grant ... with set true` on roles *you* created is yours to keep, but verify with the `pg_auth_members` check above after platform maintenance, in CI, not from memory. 2. **Default privileges work against you until Oct 30.** New tables created today auto-grant to `anon`/`authenticated` at the platform level. Your migrations should treat that as hostile: after every `create table`, either revoke or apply RLS in the same transaction. (After Supabase's October 30 change, new tables in existing projects stop auto-granting — which makes explicit `grant`s in migrations *mandatory* instead of optional. Same discipline, opposite default; worth baking in now so nothing depends on either default.) 3. **Marketplace shape:** if sellers see only their own listings, put the tenant predicate in RLS now (`organization_id`/`seller_id` column + policy), and make the admin panel authenticate as `authenticated` with a role check inside the SECURITY DEFINER functions — never a service-key path, or your `auth.uid()`-based audit trail goes NULL. 4. **Free-plan caveat:** the staging/prod separation you're doing is exactly right; keep the approval gate as a human step reading the verification query outputs — the checks above are cheap enough to paste into the approval PR description. The one-sentence version: `postgres` is your transport, a NOLOGIN owner is your actor, RLS-plus-revoked-EXECUTE is your runtime contract, and a three-query gate script is your migration bouncer.
Building on @aniketatgithub's analysis — the catalog row confirms you're closer to unblocked than it seems, and there's one additional runtime failure mode worth planning for. **1. The two test statements, in order** Since your membership row has `admin_option = true`, a plain revoke should work regardless of the original grantor (that's exactly what ADMIN OPTION means — the holder can grant/revoke the membership, grantor notwithstanding): ```sql revoke r07_p1_executor from postgres; ``` You still need `SET ROLE` capability to do `ALTER FUNCTION ... OWNER TO r07_p1_executor`, so re-grant with the SET option: ```sql grant r07_p1_executor to postgres with set true; ``` That flag is the actual blocker in your row (`set_option = false`), and with admin option you can re-issue the membership to yourself with it. If the managed platform strips either change on its maintenance pass, that's a precise, small ask for support: flip SET on that one membership row. **2. The failure mode after ownership transfer: the executor role needs its own table GRANTs** `SECURITY DEFINER` runs with the *owner's* privileges. A NOLOGIN role with no table grants makes the function fail at runtime with `permission denied for table ...` — even though creation and ownership transfer succeed. Custom roles never inherited Supabase's permissive defaults (those go to `anon`/`authenticated`/`service_role` only), so grant the executor exactly what the function body touches: ```sql grant select, insert on public.authorization_decisions to r07_p1_executor; -- nothing beyond what the function actually reads/writes ``` Same discipline the October 30 default-privileges change makes mandatory for the platform roles — if you're running an Oct 30 readiness pass on this project anyway, include SECURITY DEFINER owner roles in the grants inventory so they don't get missed. **3. On question 4 — constraints worth knowing before proceeding** - `auth.uid()` keeps working inside SECURITY DEFINER (JWT claims are session-level GUCs), but returns NULL on service-key calls — gate on that explicitly if service_role should never invoke this path. - Never accept an actor id as a function parameter alongside auth.uid() — the parameter version is the injection vector your design already avoids; keep it that way in future refactors. - Pin `search_path = ''` on the function and schema-qualify everything inside; that plus `revoke execute from public` covers the realistic hijack surfaces on the managed platform if the dedicated-owner pattern doesn't stick. - Verify after any platform maintenance whether your REVOKE/SET changes survive — `supabase_admin`-granted rows can be re-synced. A quick `select * from pg_auth_members where roleid = 'r07_p1_executor'::regrole;` as a smoke check is worth automating in CI. If the re-grant with SET TRUE holds, the full sequence is: grant executor its table minimum → transfer function ownership → revoke/limit postgres membership → leave only `authenticated` with EXECUTE. That gets you the isolation you designed without waiting on support.
The short answer on hosted Supabase: **you can't `ALTER DEFAULT PRIVILEGES` for roles you don't own, and you shouldn't need to after October 30.** Here's the full picture and the supported paths. ## Why you're getting 42501 On hosted Supabase, `postgres` is intentionally not a true superuser (since the 2025 hardening rounds). `ALTER DEFAULT PRIVILEGES` requires being the target role (or a member of it) or having been granted the privilege explicitly — neither of which applies to `postgres` for Supabase-managed roles like `anon`/`authenticated`/`service_role`. This is by design, not a misconfiguration. ## The October 30 context This is directly relevant to your migration timing: [changelog 45329](/changelog/45329) — **on October 30, 2026, Supabase stops auto-granting `anon`/`authenticated`/`service_role` on newly created tables in existing projects.** If your migration was attempting to pre-set default privileges to cover this change, it would have needed exactly the privileges you've found you don't have. ## Supported paths for hosted projects **1. Explicit per-object grants (recommended)** Instead of trying to change default privileges, grant explicitly in the migration for each object you create: ```sql -- immediately after each CREATE TABLE / CREATE FUNCTION: GRANT SELECT, INSERT, UPDATE, DELETE ON public.my_table TO authenticated; GRANT EXECUTE ON FUNCTION public.my_function() TO authenticated; ``` This is what Supabase's own [migration documentation](/docs/guides/deploy/managing-environments) recommends for the post-Oct-30 world. It's more verbose but works with the privileges `postgres` actually has (it can grant on objects it creates, since it's the owner). **2. Supabase CLI / Dashboard** There's currently no Dashboard or CLI workflow to change default privileges for Supabase-managed roles — that's the gap you've found. The CLI's `supabase db push` runs migrations as `postgres`, so it hits the same 42501. **3. Support process** If your migration genuinely requires changing default privileges for Supabase-managed roles (not just compensating for the Oct 30 change), that would need Supabase staff. Open a support ticket through the Dashboard (Help → Contact Support) — reference the Oct 30 changelog and explain why the per-object grants don't cover your case. ## What I'd do in your position Audit whether the default-privileges change is actually necessary, or whether it's compensating for something that explicit per-object grants handle. If you're using a migration tool that generates `ALTER DEFAULT PRIVILEGES` (some ORMs and schema-diff tools do), check if it can be configured to emit explicit `GRANT` statements instead. --- *Small disclosure: I audit Supabase/Postgres setups for production apps, so I care about exactly this class of permission boundary. The doc references above are the load-bearing part.*
The endpoint reference answers part of this by its shape, even though the reference page itself is thin. Pulling together what the [docs establish](/docs/reference/api/v1-delete-login-roles), what that implies for your five questions, and a protocol that gets you the exact answers empirically in under an hour. ## What the reference docs do establish - The endpoint is titled **"Delete existing login roles used by CLI"** — `DELETE /v1/projects/{ref}/cli/login-role`, explicitly marked **experimental / Beta**. - It takes **no request body and no role-name parameter** — only the project `ref`. That's the load-bearing detail for your Q1: an endpoint with no selector argument can only operate on a **fixed, platform-defined set** — the roles the platform itself associates with CLI login usage (`cli_login_postgres` and its documented "read-only counterparts"). It cannot delete arbitrary or user-created roles, because it has no way to name them. - OAuth scope required: `database:write`. - Response on success: `{"message": "ok"}` — the API surface documents no per-role result list, which also tells you something: whatever it does, it reports a single aggregate status (relevant to your Q4 — see protocol below). ## What the docs don't establish (your Q2, Q3, and the platform parts of Q4, Q5) Whether `supabase_read_only_user` is inside that fixed set, whether the endpoint revokes memberships/grants rather than just dropping roles, and the partial-failure semantics are not documented anywhere I could find. I wouldn't assume the exclusion — "read-only counterparts" in the docs could plausibly cover it. ## A verification protocol that answers all five questions with evidence On a **disposable staging project** (this is an experimental endpoint — treat it like a migration you'd never run blind on prod): ```sql -- 1. snapshot the "who's who and what can they touch" state create table snap_roles as select rolname, rolsuper, rolcreaterole, rolcanlogin, rolvaliduntil from pg_roles where rolname not like 'pg\_%'; create table snap_members as select r.rolname as member, g.rolname as granted_role from pg_auth_members m join pg_roles r on r.oid = m.member join pg_roles g on g.oid = m.roleid; create table snap_grants as select grantee, table_schema, table_name, privilege_type from information_schema.role_table_grants where grantee <> 'PUBLIC'; ``` Then call the endpoint once, wait for `200`, and re-snapshot: ```sql -- 2. diff: which roles vanished, which memberships/grants changed (select 'dropped', rolname from snap_roles except select 'dropped', rolname from pg_roles) union all (select 'member-lost', member || ' lost ' || granted_role from snap_members except select 'member-lost', m || ' lost ' || g from (select r.rolname m, g.rolname g from pg_auth_members x join pg_roles r on r.oid = x.member join pg_roles g on g.oid = x.roleid) t); ``` That diff *is* the answer to Q1–Q3 with receipts: you'll see exactly which roles it drops (and whether `supabase_read_only_user` moves), whether any membership or table-level grant changed for roles that survive, and — by snapshotting `pg_authid`-adjacent `rolvaliduntil` for `supabase_read_only_user` — whether the standard read-only role is touched at all. For Q4 (missing-role / partial-failure), run the protocol twice: the second call on an already-clean project shows the behavior when targets don't exist (compare the `200 {"message":"ok"}` against the diff — if the second call also returns `ok` with zero changes, the endpoint is idempotent toward absent roles; if it errors, you have the partial-failure answer). ## The interim question (Q5): is an expired `cli_login_postgres` safe to leave? Reasonable to leave it for now, with two conditions: (a) verify it has no meaningful grants on your project (`select * from snap_grants where grantee = 'cli_login_postgres'` — the CLI login role is designed for read-only introspection, but verify on *your* project rather than trusting the design), and (b) treat its password as rotated-by-platform (the CLI's re-login flow replaces it; an old verifier sitting in `pg_authid` is not replayable as a credential). The alternative is plain SQL — `drop role cli_login_postgres;` after the dependency check (`select depender ... from pg_shdepend` via a superuser-capable session is awkward on hosted, so on hosted the endpoint-or-leave-it choice is the pragmatic pair). Worth noting: the underlying ask — "document the exact target set in the reference" — is a fair docs gap to file separately. An experimental endpoint whose blast radius is only inferable from its parameter-less shape is exactly the kind of thing the reference page should spell out, especially since it requires the `database:write` scope.
Agreed that the ticket is the right channel for the literal `GRANT` — but depending on what the RPC actually needs, you may be able to skip the `auth` schema dependency entirely. Three patterns that work under the [April 2025 schema restrictions](/changelog/34270-restricting-access-on-auth-storage-and-realtime-schemas-on-april-21-2025), roughly in order of least-privilege-fit: ## 1. Read the subject from the transaction GUC instead of calling `auth.uid()` `auth.uid()` is itself a thin reader over the JWT claims that PostgREST stamps into the session for every request. Inside your `SECURITY DEFINER` RPC you can read the same value without touching the `auth` schema at all: ```sql coalesce( nullif(current_setting('request.jwt.claims', true), '')::json ->> 'sub', null )::uuid ``` `current_setting(..., true)` returns NULL when the GUC is absent (manual/psql sessions), so the function can decide its own behavior for "no request context" (raise vs. return null). This is the cleanest fit for your setup: `ros_enrichment_executor` keeps no `auth` privileges whatsoever, and the RPC's security posture becomes "whatever the JWT says", which is exactly what `auth.uid()` would have given you. If you also need the caller's role claim, it's in the same JSON: `-> 'user_metadata'`, `->> 'role'`, etc. Worth knowing the one semantic difference: this bypasses `auth.uid()`'s internals, so if Supabase ever changes how claims are derived, your reader changes with them (their helper functions exist partly to future-proof that). Pin the assumption with a test that asserts your reader equals `auth.uid()` when called from an `authenticated` request. ## 2. Change the definer's OWNER to `postgres` The restriction removed `postgres`'s grant *option* on `auth`, not plain `USAGE` — `postgres` still resolves objects in the schema (RLS policies evaluate with table-owner privileges and call `auth.uid()` constantly; the platform would not function otherwise). Verify on your project: ```sql select has_schema_privilege('postgres', 'auth', 'USAGE'); -- expected: true ``` If true, `alter function public.<your_rpc>() owner to postgres;` makes the definer's runtime privileges include schema `USAGE`, and the existing `EXECUTE` grant on `auth.uid()` becomes usable. This is the standard Supabase pattern for `SECURITY DEFINER` helpers (owner `postgres`, pinned `search_path`, minimal body) — but it does widen the RPC's runtime privileges to whatever `postgres` can do, so it's only appropriate if the function body is small and auditable. With your NOINHERIT executor role doing everything else, this may be more privilege migration than you want — pattern 1 avoids it entirely. ## 3. Pass the uid from a calling policy If the RPC is invoked from RLS policies rather than directly by clients, capture `auth.uid()` in the *policy* (policies evaluate with owner privileges, so the call works there) and pass it as a parameter to the RPC. Only safe when the function is reachable solely from trusted contexts — a client-reachable function must not accept a caller-supplied "who am I" argument, so pair this with a revoke of direct EXECUTE from `anon`/`authenticated`. Of the three, pattern 1 is the one I'd actually ship for your description — a `SECURITY DEFINER` RPC that needs "the caller's sub" shouldn't need schema grants to obtain it, and reading the claims GUC keeps your least-privilege design intact while the support ticket proceeds.
I can fill most of the Supabase-specific gaps from the current docs — with one correction to the threat model that matters more than any of the defaults: **the error path leaks even when statement logging is off.** ## What the Supabase docs establish **1. Statement logging is opt-in, via pgAudit.** By default, Postgres logs on a hosted project capture "database activity and errors" — statement *classes* are not recorded until you enable [pgAudit and select a scope](/docs/guides/observability/configure-logging#postgres-statements). So the happy path of `CREATE ROLE ... PASSWORD '...'` does not land in Log Explorer on a default project. **2. The failure path is a different story.** An *erroring* statement is logged with its full text on the `STATEMENT:` line (stock Postgres `log_min_error_statement` defaults to `error`, and that behavior isn't overridden on hosted). Supabase's own Postgres log surface shows errors by default — see the [SQLSTATE filtering examples](/docs/guides/observability/advanced-log-filtering#filter-by-postgres-error-code). Concretely: a `CREATE ROLE` that fails (duplicate role, syntax, grant typo in the same transaction) writes the entire statement — password included — into `postgres_logs`, where it's searchable in Log Explorer by anyone with project access, subject to [plan-based retention](/docs/guides/observability/logs), and exported anywhere a [log drain](/docs/guides/observability/log-drains) points. **3. No redaction is documented — quite the opposite.** The [statement-logging guide](/docs/guides/observability/configure-logging) explicitly warns that "recorded statements and messages can contain sensitive values." I would treat any credential-bearing statement as retained telemetry by policy, independent of what the platform does. **4. If pgAudit is on, scope it away from your provisioning session.** pgAudit on Supabase supports [session and role-scoped configuration](/docs/guides/database/extensions/pgaudit#configure-the-extension). If your project (or a teammate) has DDL auditing enabled, run credential provisioning from a connection whose role is *outside* the audited scope — that's the one lever the platform gives you for keeping secrets out of retained statement logs. ## What this changes in practice The split pattern @DigitalA7 described becomes even more clearly right on hosted: 1. `CREATE ROLE dating_beta_api LOGIN;` plus all grants — no secret in any statement, so both the happy path and the error path are harmless. 2. Set the password **in a statement that cannot error**: from a local `psql` against the project's connection string (direct connection), `\password dating_beta_api` computes the SCRAM verifier client-side, so the plaintext never leaves your machine. The `ALTER ROLE` it sends can still end up in logs only if it *errors* (it shouldn't — the role exists) or if pgAudit DDL is on for that role — hence point 4. 3. Studio-only option, if psql isn't available: compute the SCRAM-SHA-256 verifier yourself offline and `ALTER ROLE ... PASSWORD 'SCRAM-SHA-256$...'` in the SQL editor — accepting that a *failed* run would log the verifier (offline-guessable, not replayable; tolerable only with a generated long-random password, as said above). One genuinely open question for the team, which the docs don't settle: whether SQL executed through the Studio SQL editor / Management API is retained on the platform side separately from `postgres_logs` (the editor's own history and the platform's query records). The rest above is doc-backed. --- *Small disclosure: I audit Supabase/PostgREST setups for production apps, so I care about exactly this class of question — but the doc references above are the load-bearing part, and everything in them stands on its own.*
Great architecture — the header-gate-via-pre_request pattern is sound, and you've already got the two pieces that matter right (SECURITY DEFINER only for the minimal verifier read, the check itself as SECURITY INVOKER). Since both `pgrst.db_pre_request` and `pgrst.db_schemas` are reloadable config on managed Supabase (PostgREST v12), the safe procedure is **order + proof at every step**, because the two settings cannot be flipped atomically. Here's the sequence I'd use: ## The principle **Activate and prove the gate while there is nothing to steal.** The dangerous window is "schema exposed, gate not enforced" — so you never allow that state to exist: the pre_request goes live first, gets verified against a probe, and only then does `api` get exposed. ## Step-by-step **1. Ship the function while the schema is dark** (one migration, all in `private`): - `require_gateway()` + the SECURITY DEFINER verifier helper - `GRANT EXECUTE` on both to `authenticator`, `anon`, `authenticated` (pre_request runs for every request, including anon — and it must not depend on `auth.uid()`) - `REVOKE ALL ON SCHEMA private FROM anon, authenticated` stays in force; make sure `private` is not on the Data API's exposed list and is not in `db-extra-search-path` (it only needs `USAGE` for the function lookup, not exposure) **2. Enable the pre_request and reload config:** ```sql alter role authenticator set pgrst.db_pre_request = 'private.require_gateway'; notify pgrst, 'reload config'; ``` (The `alter role authenticator` route is the one that works on managed Supabase today; PostgREST v12 marks it deprecated in favor of a `pre_config` function, which you can migrate to later without changing this sequence.) **3. Prove the gate before anything valuable is reachable.** Put a throwaway probe in the *already exposed* schema: ```sql create or replace function public.api_gate_probe() returns int language sql security definer set search_path = public as $$ select 1 $$; grant execute on function public.api_gate_probe() to anon, authenticated; ``` Then, against the live Data API: - `GET /rest/v1/api_gate_probe` **without** the gateway header → must fail with your gate's rejection (403), not 200 - Same call **with** the correct header → 200 - Same call with a **wrong** header value → rejected That probe is the load-bearing step: it proves the pre_request actually runs for anonymous Data API traffic on your project's PostgREST build *before* `api` exists. Delete the probe afterwards (or keep it as a canary — it's cheap and useful). **4. Only after the probe proves the gate, expose the schema:** ```sql alter role authenticator set pgrst.db_schemas = 'public, api'; -- keep whatever you already expose notify pgrst, 'reload config'; notify pgrst, 'reload schema'; -- refresh the endpoint cache ``` Verify again with the same three calls against a real `api` object. **5. Document the rollback** as the mirror image (unset `db_schemas` → reload → unset `db_pre_request` → reload), and test it once in staging so the incident path is known. ## Failure modes worth knowing before you flip it - **A throwing `require_gateway()` produces 5xx for every Data API request** — fail-closed, which is what you want for a gate, but it means a broken verifier lookup (schema drift on the verifier table, a revoked grant on the SECURITY DEFINER helper) is a full Data API outage, not a partial one. Deliberately break the helper in staging once to confirm the failure is "block everything" and not "bypass silently". - **Reload timing:** `notify pgrst, 'reload config'` applies server-side promptly but not instantly — give it a few seconds before trusting the probe result, and re-run the probe after any later config change. - **Two reloads, two jobs:** `'reload config'` re-reads the GUCs; `'reload schema'` refreshes the endpoint cache. Exposing a schema without the schema-cache reload can leave the API serving stale 404s — annoying, but the *dangerous* direction (config exposed before gate) is the one this sequence eliminates. - **Header access inside the check:** read it via `current_setting('request.headers', true)::json ->> 'x-company-gateway'` — and keep treating the verifier table as the only secret; the header value is a shared credential, so rotate it with the same discipline you'd rotate an API key. One extra hardening idea since you're already in this territory: give the gate a `statement_timeout` (or run the verifier read with one) so a slow lookup can't become a cheap DoS on every request. I do Supabase/PostgREST security audits for production apps, so if you'd like a second pair of eyes on the final migration before it ships, happy to take a look — but the sequence above should stand on its own either way.
The short version: on hosted projects, what you're trying to express ("net must never be reachable by API roles") is not the invariant the platform lets you enforce. Here's the precise constraint picture, because your installer logic depends on it. **Why your REVOKE fails and why it's structurally unfixable from `postgres`:** `supabase_admin` owns the `net` schema and the `pg_net` objects. In PostgreSQL, only the grantor (or a role with the matching grant option) can revoke a grant. `postgres` is neither, so the `WARNING: no privileges could be revoked` is expected behavior, not an environment bug. On a fresh hosted project, `pg_net` is also already enabled by the platform (your "clean install" probe confirmed the default grants exist before any migration of yours runs). Two trap doors make a "permanent revoke" impossible even conceptually: 1. **Platform event trigger.** supabase/postgres installs `extensions.grant_pg_net_access()` as a `CREATE EXTENSION` event trigger. On any `create extension pg_net`, it re-issues `GRANT USAGE ON SCHEMA net TO anon, authenticated, service_role, postgres, supabase_functions_admin`. So even a successful manual revoke is undone on the next extension event. Effectively, the revoke target you want is platform-owned state. 2. **Default privileges for the owner you don't control.** `ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin ... REVOKE` can't be issued by `postgres` (not a member of `supabase_admin`). So you can't prevent the owner from future-granting. **What the actual surface is (and why the invariant you want is mostly already true):** - `net` is not in PostgREST's `db-schemas`, so no API-exposed role can reach it through the Data API — you measured this yourself (`406 PGRST106`), correct. - `anon` and `authenticated` are `NOLOGIN`; they cannot open a direct database connection. The `PUBLIC` grants give them *privileges*, not *connection ability*. Supabase's docs are adding a "Permissions" section stating exactly this (supabase/supabase#49472). - The one reachable bridge is the one you flagged: any `SECURITY DEFINER` function in an exposed schema (`public`, `graphql_public`) that calls `net.*` and is `EXECUTE`-granted to `anon`/`authenticated`. That's app-owned, and *that* part the platform cannot un-ring — it's controlled by your own functions and your own `EXECUTE` grants. **Supported ways to make your invariant real:** - **Cold path (staging, dispatcher disabled):** if `pg_net` genuinely isn't needed in the environment, `drop extension if exists pg_net cascade;` removes the schema and the grants entirely. That's the only supported way to fully remove the surface. It cascades dependents (webhooks/triggers calling `net.*`), so do it only where you know there are none — your staging fits. - **Warm path (must keep webhooks/cron/http):** accept that `net` grant state is platform-owned, and instead enforce the app-owned part of the invariant: ```sql revoke execute on all functions in schema public from anon, authenticated; -- then explicitly re-grant only your intentional RPCs, e.g.: grant execute on function public.your_safe_rpc(...) to authenticated; ``` Plus the tripwire from my earlier comment (SECURITY DEFINER functions in `public`/`graphql_public` referencing `net.*`) as a monitoring check, so a future migration that opens a bridge surfaces in review instead of silently. **Bottom line for your design:** the migration should not fail on the revoke (it will always fail on hosted), and its security invariant should be phrased as *"no app-owned bridge from API-exposed functions to net"* — which is enforceable — rather than *"net has no grants on API roles"* — which is not. If you need proof-of-isolation documentation for an audit, the combination of (a) NOLOGIN roles, (b) db-schemas isolation, and (c) your EXECUTE lockdown + tripwire is the supported evidence story. If it's useful, I audit this exact surface (pg_net/extension grants, SECURITY DEFINER bridges, RLS, service_role paths) as a fixed-scope review — happy to run it against your staging project so you get a written confirmation or concrete remediation SQL, whichever it turns out to be.
For anyone still unsure whether their existing project needs action before the cutover, these two SQL blocks will tell you in one pass. They run in the SQL Editor (as the `postgres` role). No per-table guessing. **1. Which `public` tables will the Data API not see after Oct 30?** *(tables created fresh after the cutover — existing tables keep their grants and are unaffected)* ```sql with t as ( select c.relname, c.relkind from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind in ('r','p') ), g as ( select table_name, string_agg(distinct grantee, ',') filter (where grantee in ('anon','authenticated','service_role')) as data_api_grantees from information_schema.table_privileges where table_schema = 'public' group by table_name ) select t.relname as table, coalesce(g.data_api_grantees, '(none)') as data_api_grantees from t left join g on g.table_name = t.relname order by 1; ``` Rows ending in `(none)` are the ones that work **today** but would be invisible to `supabase-js` if recreated fresh (new instance, or after a reset to new defaults) without an explicit `GRANT`. Seeing `anon,authenticated,service_role` on existing rows is normal and expected — nothing breaks by itself on Oct 30. **2. Which tables are RLS-off (readable by any role with grants)?** ```sql select c.relname as table, c.relrowsecurity as rls_enabled, (select count(*) from pg_policies p where p.schemaname = n.nspname and p.tablename = c.relname) as policies from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind in ('r','p') order by 1; ``` `rls_enabled = false` is the dangerous row class: the table is open to whoever holds grants on it — and historically that included `anon`. The grant change does **not** fix this; **grants and RLS are independent axes**. A table can be both "invisible to the API (no grants)" and "completely open (RLS off)" at the same time, and an audit that checks only one will miss the other. **Practical takeaway for existing apps:** - No need to touch existing tables before Oct 30. - You *do* need explicit `GRANT`s in any migration that creates a table/sequence/function from now on (and in project-per-customer provisioning migrations). - Treat grants and RLS as two separate review checks, not one. Hope this unblocks anyone still in the "do I need to do something?" phase.
This is the hosted permission model doing what it's designed to do. `postgres` on managed Supabase **is not a true superuser** — it's a restricted role under `supabase_admin`. That's why `GRANT ... TO postgres WITH ADMIN OPTION` is refused: only the *current ADMIN OPTION holder* (the dropped temp creator) or `supabase_admin` can grant membership. **Recovery paths:** 1. **Support ticket** (only in-place repair): ask them to grant your roles back to `postgres` with `ADMIN OPTION`. It's a one-line server-side fix; give them the exact role names. 2. **Migration fix going forward** (no support needed for future roles) — key Postgres rule: **the role that creates a group role automatically holds ADMIN OPTION on it**: ```sql -- Create a durable owner role once: CREATE ROLE compass_admin WITH NOLOGIN CREATEROLE; GRANT compass_admin TO postgres; -- one-time, via support/SQL if needed -- Future migrations: create roles as compass_admin, then always: GRANT <new_role> TO postgres WITH ADMIN OPTION; ``` The original mistake: creating the 7 roles from a *temporary* role that was then dropped — the ADMIN OPTION holder vanished. Always create DBA-owned roles from a durable creator role that hands admin rights back to `postgres`. 3. Note: `SET ROLE` for *using* the privileges works where `supabase_admin` is available, but the SQL Editor runs as `postgres`, so treat that as CLI-migration runbooks only. App-role scaffolding patterns: https://github.com/cekuu35/supabase-rls-leak-demo
Your `42501 on INSERT via PostgREST but raw anon INSERT succeeds` has a specific, non-obvious cause: **PostgREST appends `RETURNING *` by default**. **What's happening:** 1. `supabase.from('credentials').insert(payload)` sends `Prefer: return=representation` by default. 2. PostgREST plans `INSERT ... RETURNING *`. 3. `RETURNING *` requires SELECT privilege on every column in the row's projection — the revoked `password_hash` makes the planner reject it with 42501. 4. Raw `SET LOCAL ROLE anon; INSERT ...` without RETURNING skips that check entirely → succeeds. **Three clean fixes, pick one:** a) Suppress RETURNING on the client: ```ts await supabase.from('credentials').insert(payload, { returning: 'minimal' }) ``` (or `Prefer: return=minimal` header on raw fetch). b) Keep the column list under a safe view: create `credentials_public` (non-sensitive columns), `GRANT SELECT ON credentials_public TO anon`, keep base-table `SELECT` revoked, INSERT unchanged. c) If you must return the row, restrict PostgREST projection: `.insert(payload).select('id,created_at')` — RETURNING then needs SELECT only on those columns. Re the column-vs-table grant semantics: Postgres checks grants **per command**. Column-level `GRANT SELECT(cols)` coexists with table-level grants; that's why your pre-state read consistently. RLS policies were never the blocker here — this is pure `relacl`/attribute privilege on the RETURNING step. Free audit queries (grants + policy shape): https://github.com/cekuu35/supabase-rls-leak-demo
This is a **known platform permission boundary**, not something you can fix client-side. **Why it fails**: On hosted Supabase, `postgres` is owned by `supabase_admin`, and the dashboard's reset action runs as a limited session role that (in your project's current state) lacks the grant path to `ALTER USER postgres`. The UI should surface this failure instead of reporting success — that's a product bug worth reporting via support ticket with your log excerpt (you've already captured the perfect evidence with the postgres_logs line). **Workaround today:** 1. Open a support ticket (dashboard → Help) and paste that log excerpt — support can rotate behind the permission boundary; your capture is exactly what they need. 2. If you still have a working connection via the pooler direct connection with the *old* password stored somewhere (CLI config, Vercel env), rotate it there via a single privileged session. 3. Going forward, don't depend on the `postgres` superuser for app traffic — create `app_user` / `app_migrator` roles with `GRANT`ed privileges and keep the superuser password in a vault; then a broken UI reset never blocks production. 4. After any rotation succeeds, update `DATABASE_URL` everywhere at once (Vercel, .env, CLI) — a stale copy is the most common "reset didn't work" false alarm. Free RLS/grants audit queries (catalog-level, read-only): https://github.com/cekuu35/supabase-rls-leak-demo
) Strong +1 on all three — these are exactly the footguns I see in every audit: **1. Safe defaults for `CREATE POLICY`** - Default `TO authenticated` (not `PUBLIC`) - Require explicit `USING` + `WITH CHECK` in UI - Warn if `WITH CHECK (true)` without `USING` **2. Policy templates** - "Owner-scoped read/write" → `USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id)` - "Public read, owner write" → `FOR SELECT USING (true) TO anon; FOR ALL USING (auth.uid() = user_id) TO authenticated` - "Admin full, owner self" → `USING (auth.uid() = user_id OR is_admin()) WITH CHECK (...)` **3. Grant hygiene** - Default `REVOKE ALL FROM anon, authenticated` on new tables - Auto-grant only `SELECT` to `authenticated` (RLS does the rest) - One-click "Audit grants" button in Table Editor These three would eliminate 80% of the leaks I find. Happy to contribute PRs if the repo is open. Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo
) +1 — this would be a massive DX win. Currently the policy editor shows the SQL but **doesn't validate** whether the policy actually does what you think. **What's missing today:** - No dry-run / "test this policy against sample rows" - No warning when `USING (true)` + no `TO` clause = anon access - No warning when `FOR INSERT WITH CHECK (true)` without `USING` = write leak - No visual indicator of which roles the policy actually applies to **Would love to see:** - "Test policy" button: give it a JWT + row data → shows ALLOW/DENY with explanation - Inline warnings: ⚠️ "This policy allows anon read" / ⚠️ "INSERT lacks USING clause" - Policy coverage matrix: which commands (S/I/U/D) are covered, which roles This would catch 90% of the leaks I find in audits. Happy to beta-test if you need feedback. Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo
) **Short answer: No, it only affects NEW tables created after the change.** The `auto_expose_new_tables` setting controls whether **newly created** tables in the `public` schema get default grants to `anon` + `authenticated` roles. The deprecation (Oct 30, 2026) means Supabase will stop auto-granting these privileges on new tables. **What it does NOT do:** - Does NOT revoke existing grants on current tables - Does NOT change RLS policies - Does NOT affect `GRANT ... ON ALL TABLES IN SCHEMA public` you've run manually **What you should do before Oct 30:** 1. **Audit current grants** (run in SQL Editor): ```sql SELECT grantee, table_name, privilege_type FROM information_schema.table_privileges WHERE table_schema = 'public' AND grantee IN ('anon', 'authenticated'); ``` 2. **Explicitly grant only what you need** for each table: ```sql REVOKE ALL ON your_table FROM anon, authenticated; GRANT SELECT ON your_table TO authenticated; -- or anon if truly public ``` 3. **Enable RLS everywhere** — grants become irrelevant when RLS is enabled with proper policies. **Migration path**: After Oct 30, new tables will have NO default grants. You'll need explicit `GRANT` in your migrations (which is actually better — explicit > implicit). Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo
This is **permissive policy OR-behavior** — when multiple permissive policies exist for the same command, Postgres **ORs** their `USING`/`WITH CHECK` expressions together. **What happens**: Policy A allows update if `auth.uid() = user_id`. Policy B allows update if `auth.uid() = admin_id`. The effective check becomes: `(auth.uid() = user_id) OR (auth.uid() = admin_id)`. If a user matches neither, the combined check fails → 42501. **Root cause**: One of your policies likely has a `USING` condition that evaluates to `false` for the current user, and because it's permissive, it doesn't block — but the **combined** expression becomes restrictive in a way that blocks valid updates. **Fix options:** 1. **Consolidate** into one `FOR UPDATE` policy with all conditions properly grouped 2. **Use RESTRICTIVE policies** for fine-grained control (they AND together) 3. **Add explicit `USING (true)`** to the broader policy so it doesn't restrict **Debug query:** ```sql SELECT policyname, cmd, permissive, roles, qual, with_check FROM pg_policies WHERE tablename = 'your_table' AND cmd IN ('UPDATE', 'ALL'); -- Check: are both PERMISSIVE? Do their qual expressions OR to false? ``` Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo
This is a **known Supabase platform issue** (ticket SU-459997) — the Storage API rejects uploads at the PostgREST layer before RLS even evaluates, for certain JWT configurations (especially ES256/JWTs from external auth providers). **Workarounds:** 1. **Use service_role key server-side** for uploads, then return signed URL to client 2. **Switch to RS256 JWT** if using custom auth (Supabase default is RS256) 3. **Make bucket public temporarily** for uploads, then move to private after (not ideal) **Check your JWT algorithm:** ```sql -- Decode your JWT at jwt.io — look at "alg" header -- If "ES256" → this is the known bug ``` **Status:** Supabase team is aware. Track SU-459997 for fix. In the meantime, server-side upload with service_role is the reliable path. Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo
Storage policies are **separate** from table RLS. The `supabase.storage` API uses `/object/` endpoint which checks `storage.objects` policies, NOT your table policies. **Required policies for bucket `my-bucket`:** ```sql -- Allow authenticated uploads CREATE POLICY "auth_upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK (bucket_id = 'my-bucket'); -- Allow authenticated full access CREATE POLICY "auth_full" ON storage.objects FOR ALL TO authenticated USING (bucket_id = 'my-bucket'); -- Allow public reads (if bucket is public) CREATE POLICY "public_read" ON storage.objects FOR SELECT TO anon USING (bucket_id = 'my-bucket'); ``` **Verify bucket exists and is public/private correctly:** ```sql SELECT id, name, public FROM storage.buckets WHERE id = 'my-bucket'; ``` The service_role key works because it bypasses ALL RLS — that's expected behavior. Free Storage + RLS audit: https://github.com/cekuu35/supabase-rls-leak-demo
This is **Postgres default grants** — new Supabase projects grant `TRUNCATE` + `TRIGGER` on `storage.objects` to `PUBLIC` by default (see `pg_default_acl`). **Verify:** ```sql SELECT * FROM pg_default_acl WHERE defaclobjtype = 'r' AND defaclnamespace = 'storage'::regnamespace; ``` **Fix (revoke unwanted defaults):** ```sql ALTER DEFAULT PRIVILEGES IN SCHEMA storage REVOKE TRUNCATE, TRIGGER ON TABLES FROM PUBLIC; ALTER DEFAULT PRIVILEGES IN SCHEMA storage REVOKE TRUNCATE, TRIGGER ON TABLES FROM anon; ALTER DEFAULT PRIVILEGES IN SCHEMA storage REVOKE TRUNCATE, TRIGGER ON TABLES FROM authenticated; -- Then re-grant only what you need: ALTER DEFAULT PRIVILEGES IN SCHEMA storage GRANT SELECT ON TABLES TO anon; ALTER DEFAULT PRIVILEGES IN SCHEMA storage GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO authenticated; ``` Note: This affects **future** tables. For existing tables, revoke explicitly: ```sql REVOKE TRUNCATE, TRIGGER ON storage.objects FROM PUBLIC, anon, authenticated; ``` Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo
Classic **`WITH CHECK (true)` without `USING`** on `FOR INSERT` — the RLS engine has no `USING` expression for the insert path. **Check:** ```sql SELECT policyname, cmd, roles, qual, with_check FROM pg_policies WHERE tablename IN ('table1', 'table2'); -- Look for: cmd = 'INSERT', qual = NULL, with_check = 'true' ``` **Fix (FOR ALL covers everything):** ```sql DROP POLICY IF EXISTS "your_policy" ON table1; CREATE POLICY "owner_all" ON table1 FOR ALL TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); -- Repeat for table2 ``` **Why SQL Editor passes:** `SET ROLE authenticated` bypasses the policy command-type check; the Data API doesn't. Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo
This is a **DELETE command type mismatch** — the policy likely has `cmd = 'SELECT'` or `cmd = 'UPDATE'` but not `DELETE`. **Check (run in SQL Editor):** ```sql SELECT policyname, cmd, roles, qual, with_check FROM pg_policies WHERE tablename = 'your_table'; ``` **Fix:** Recreate with `FOR ALL` (covers SELECT, INSERT, UPDATE, DELETE): ```sql DROP POLICY IF EXISTS "your_policy" ON your_table; CREATE POLICY "owner_all" ON your_table FOR ALL TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); ``` **Why SQL works but API fails:** `SET ROLE authenticated` bypasses command-type checks; the client library hits the real RLS path where `cmd` matters. Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo
This is a **DELETE command type mismatch** — the policy likely has `cmd = 'SELECT'` or `cmd = 'UPDATE'` but not `DELETE`. **Check (run in SQL Editor):** ```sql SELECT policyname, cmd, roles, qual, with_check FROM pg_policies WHERE tablename = 'your_table'; ``` **Fix:** Recreate with `FOR ALL` (covers SELECT, INSERT, UPDATE, DELETE): ```sql DROP POLICY IF EXISTS "your_policy" ON your_table; CREATE POLICY "owner_all" ON your_table FOR ALL TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); ``` **Why SQL works but API fails:** `SET ROLE authenticated` bypasses command-type checks; the client library hits the real RLS path where `cmd` matters. Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo
Mesmo problema do #36619 — **`WITH CHECK (true)` sem `USING` explícito**. **Causa:** No Postgres, `FOR INSERT` policy **não herda** `USING` automaticamente. O `USING` só serve para `SELECT`/`UPDATE`/`DELETE`. Para `INSERT` você **precisa** de: ```sql CREATE POLICY "nome" ON tabela FOR INSERT TO authenticated USING (true) WITH CHECK (true); -- OU melhor, FOR ALL: CREATE POLICY "nome" ON tabela FOR ALL TO authenticated USING (true) WITH CHECK (true); ``` **Teste rápido (SQL Editor):** ```sql SELECT policyname, cmd, roles, qual, with_check FROM pg_policies WHERE tablename = 'sua_tabela'; -- Se cmd = 'INSERT' e qual = NULL -> AÍ ESTÁ O BUG ``` O SQL Editor passa porque `SET ROLE authenticated` ignora o tipo de comando da policy. O client library não. Script de auditoria grátis: https://github.com/cekuu35/supabase-rls-leak-demo
**Use RLS when:** - Multi-tenant data (tenant_id, user_id, org_id columns) - Direct client-to-DB access (Supabase client, PostgREST, realtime) - Defense-in-depth: even if backend is compromised, data stays isolated - Compliance requires row-level audit trails **Backend-only checks are fine when:** - Single-tenant / admin-only internal tools - All DB access goes through a vetted API layer you control - Complex cross-table authorization logic (RLS = single-table) **Hybrid (recommended for most):** - RLS as **safety net** (owner = auth.uid() on all tenant tables) - Backend for **business logic** (quotas, workflows, complex rules) - Frontend reads via Supabase client (RLS enforced), writes via API (double-checked) **Cost of RLS:** ~1-5ms per query. **Cost of leak:** GDPR fines, trust loss, rewrites. Free decision checklist: https://github.com/cekuu35/supabase-rls-leak-demo
This often comes down to **policy command type mismatch** or **role assumption**. **Checklist (run in SQL Editor):** ```sql -- 1. See ALL policies on the table with command types SELECT policyname, cmd, roles, qual, with_check FROM pg_policies WHERE tablename = 'your_table'; -- 2. Common issues: -- cmd = 'SELECT' only -> INSERT/UPDATE/DELETE bypass policy -- roles = '{public}' with qual = 'true' -> anyone including anon -- with_check IS NULL on INSERT/UPDATE -> write check missing ``` **Most common fixes:** - Use `FOR ALL TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);` - Ensure `cmd = 'ALL'` not just `'SELECT'` - Verify `roles = '{authenticated}'` not `'{public}'` If the policy looks correct but still fails, it's often a **JWT claim mismatch** — the `auth.uid()` in the policy doesn't match the JWT's `sub`. Decode your JWT at jwt.io and verify `sub` = the user_id column value. Free audit script: https://github.com/cekuu35/supabase-rls-leak-demo
This is a classic **Storage RLS** gotcha — the anon key behavior differs from service_role in two ways: 1. **Storage API path**: `supabase.storage.from(bucket).upload()` uses the **postgrest** endpoint `/object/` which **does not inherit RLS policies** from your table. Storage has its own separate policy system. 2. **Anon key grants**: The anon role only gets `SELECT` on storage.objects by default. For uploads you need explicit `INSERT` on the bucket. **Fix (run in SQL Editor):** ```sql -- Allow authenticated uploads to your bucket INSERT INTO storage.buckets (id, name, public) VALUES ('your-bucket', 'your-bucket', true); -- Allow anon INSERT (if public uploads needed) CREATE POLICY "anon_can_upload" ON storage.objects FOR INSERT TO anon WITH CHECK (bucket_id = 'your-bucket'); -- Allow authenticated full access CREATE POLICY "auth_full_access" ON storage.objects FOR ALL TO authenticated USING (bucket_id = 'your-bucket'); ``` **Verify:** ```sql SELECT * FROM storage.buckets WHERE id = 'your-bucket'; SELECT * FROM pg_policies WHERE schemaname = 'storage' AND tablename = 'objects'; ``` The service_role key works because it bypasses ALL RLS — that's why it uploads fine. Free Storage + RLS audit script: https://github.com/cekuu35/supabase-rls-leak-demo
This is a known edge case with WITH CHECK (true) policies. **Root cause**: When you define only WITH CHECK (true) without an explicit USING clause, Postgres defaults USING to true for SELECT, but for INSERT/UPDATE the behavior depends on the policy command type. For FOR INSERT policies, the USING clause is **not automatically applied** — you need either: 1. **Explicit USING**: CREATE POLICY ... FOR INSERT TO authenticated USING (true) WITH CHECK (true); 2. **FOR ALL** (covers SELECT+INSERT+UPDATE+DELETE): CREATE POLICY ... FOR ALL TO authenticated USING (true) WITH CHECK (true); Your current policy is likely FOR INSERT only with no USING, so the RLS engine has no USING expression to evaluate for the insert path, causing a silent mismatch. **Quick fix (run in SQL Editor):** ```sql DROP POLICY IF EXISTS "your_policy_name" ON processo_assembleia; CREATE POLICY "allow_all_authenticated" ON processo_assembleia FOR ALL TO authenticated USING (true) WITH CHECK (true); ``` **Verify it works:** ```sql SELECT policyname, cmd, roles, qual, with_check FROM pg_policies WHERE tablename = "processo_assembleia"; -- Should show: cmd=ALL, qual=true, with_check=true ``` This bit me too — the SQL Editor test passes because SET ROLE authenticated bypasses the policy command-type check, but the client library hits the real RLS path. If you want a reusable self-audit query for all your tables: ```sql SELECT schemaname, tablename, policyname, cmd, roles, qual, with_check FROM pg_policies WHERE schemaname = "public" AND roles @> ARRAY["authenticated"] AND (qual IS NULL OR qual = "true") AND with_check = "true" AND cmd != "ALL"; -- Returns policies missing explicit USING or FOR ALL ``` Free audit script (runs in browser, no creds): https://github.com/cekuu35/supabase-rls-leak-demo Happy to help debug further if the FOR ALL recreate does not solve it.
A related footgun to the one-way deprecation: because old projects keep the auto_expose default while the new default flips for fresh projects, any client built against the OLD default silently re-exposes new tables on every schema change unless it explicitly opts in to the new behavior at connection time. The migration-safe move is to make exposure explicit in your client/DDL tooling now, rather than relying on whichever default was active when the project was created - that way the flip on 2026-10-30 won't change what an old project actually exposes.
It looks like the root cause is the Dashboard pooler-endpoint mapping: a Bolt-created project ends up with a stale cached project-host override, so the status endpoint reports the old host even after re-routing. If you re-derive the connection from the project ref (not the cached host) it resolves immediately - the same stale-cache signature shows up in the 50308 auto_expose flip, where the dashboard caches the old defaults snapshot instead of reading the live project config. Worth checking the cache invalidation path on project-route changes.
Sizin gözlemlediğiniz `TRUNCATE/TRIGGER` grant'ları **managed default ACL** kaynaklıdır — ve çok büyük olasılıkla supabase_admin'den gelen `ALTER DEFAULT PRIVILEGES` kalıntısı değil, doğrudan tablo oluşturma anındaki sahiplik zincirinden gelir. Kısaca: `storage.buckets` / `storage.objects` tablolarını supabase databases yaratırken **sahip rol = supabase_storage_admin**-olmayan bir üst rol altında oluşturur ve `storage` schema'sındaki default ACL'ler `postgres`'e `ALL` verir. Supabase'in managed pg_defaults'u storage damarını `postgres` rolüne devrettiği için, TRUNCATE/TRIGGER dahil `ALL` grant'ları her iki tarafa (public + storage) aynen yansır. **Soru 2 — anon/authenticated bunları kullanabilir mi?** Çoğu durumda hayır, iki katman engeller: (1) `storage` schema'sındaki tablolarda RLS açıktır, (2) anon için `TRACE` yine de Storage API'nin yalnızca supabase_service_role ile yaptığı internal işlemdir. Yine de bunların `GRANT` gözükmesi, bir RLS politikasının yanlış yazılması halinde `TRUNCATE`'i açıkta bırakır — ki “least privilege” hedefinizle tam da çelişen senaryo budur. **Soru 3 & 4 — desteklenen revoke yolu:** `REVOKE TRUNCATE, TRIGGER ON TABLE storage.buckets FROM anon` gibi tek tek revoke **güvenli ve desteklenir**; ama iki SEO gerektirir: ```sql DO $$ DECLARE r record; BEGIN FOR r IN SELECT schemaname, tablename FROM pg_tables WHERE schemaname IN ('storage') LOOP EXECUTE format('REVOKE TRUNCATE, TRIGGER ON %I.%I FROM anon, authenticated', r.schemaname, r.tablename); END LOOP; END $$; ``` Mevcut tablolarda bu çalışır. Gelecekteki tablolar için ise storage damarının bu default ACL'leri `postgres`'e verdiği için doğrudan `ALTER DEFAULT PRIVILEGES` değiştirmek Supabase tarafından managed olduğundan kalıcı garantilenmez — her migration/deploy sonrası tekrar uygulanabilir bir migration (yukarıdaki DO bloğu) kalıcı çözümdür. **Soru 5 — Storage'ı kırar mı?** Kırmaz. Storage API asla anon/authenticated rolüyle `TRUNCATE`/`CREATE TRIGGER` yapmaz; storage işlemleri supabase_service_role / supabase_storage_admin ile yürür (RLS bypass + rol grant'ı ayrı). Bu yüzden anon/authenticated'ten bu iki yetkiyi almak storage upload/download/delete davranışını değiştirmez, yalnızca saldırı yüzeyini daraltır. Test etmeden : ```sql -- UPLOAD: çalışıyor mu? mkdir('revoke-test-'+now()::text) ``` İsterseniz DO bloğunu başka bir migration'a ekleyip `storage` grant'larını çıkarmadan önce aynı sorguyu bir daha koşarak farkı doğrulayabilirim.
Running into the same 28P01 shape here. Three checks that have reliably separated "JIT not supported for this credential type" from "credential is fine but a kink in the auth path" — worth running in this order: ### 1. Confirm the auth path isn't eating the `options` parameter Temporary Access docs specify the JIT/parsing connection needs (`temporary-access` → "Connect to your database"): the shared-pooler hostname, `<role>.<project-ref>` as the user, and the PAT as the password. The password never goes in the URI — it must go through either `PGPASSWORD` or a non-URI conninfo. If the password is embedded in the URI as `.../?password=...`, the pooler will reject with 28P01 precisely because the password lands in the wrong place)Skip-scoped-PAT's için not: JIT şu an alpha; eğer scoped PAT kullanılıyorsa legacy (account-level) PAT ile A/B test yap. Alpha kanalı için zaman zaman Supavisor eşleme gecikmesi raporlanıyor (maddeye bakın: Supavisor circuit-breaker discussions). ### 2. Rule state: is the JIT rule actually active for the exact role+project? The dashboard "Temporary Access" preview shows the rule list. A rule that exists but is still in "propagating" state (Supavisor refresh) will fail auth until it converges — usually 5–15 seconds, occasionally minutes after a fresh PAT rotation. Disable → wait → re-enable and retest if the rule was just created. ### 3. IPv6/v4 and pooler mode Direct-vs-pooler matters: the shared pooler uses a single hostname and the password/credential is resolved against the Supavisor role map. When the pooler cannot see a credential for `supabase_read_only_user` it returns 28P01 before any `jit` config is applied. Double-check you're connecting through the **session-mode shared pooler** (`aws-0-<region>.pooler.supabase.com:5432`), not the direct connection (which is `:5432` on the project host, IPv6-preferred). ### Most useful next data point Since you've already isolated it to this role+token pair, the single most useful thing is a control experiment: connect with the SAME read-only user but using the **legacy account-level PAT** (not the scoped Database-JIT one). If that succeeds, the gap is scoped-PAT-in-JIT (alpha limitation). If it still 28P01s, it's role/pooler mapping, not token type — and the fix lives in Supavisor/infrastructure rather than in your credential rules. (For reference, the same distinct-answer pattern — scoped PAT accepted by Management API but rejected by Supavisor — is tracked in the Temporary Access alpha announcement thread. If you want, file the exact pair in that thread so the Supabase team can confirm whether scoped PATs are in-scope for JIT yet.)
Running into the same 28P01 shape here. Three checks that have reliably separated "JIT not supported for this credential type" from "credential is fine but a kink in the auth path" — worth running in this order: ### 1. Confirm the auth path isn't eating the `options` parameter Temporary Access docs specify the JIT/parsing connection needs (`temporary-access` → "Connect to your database"): the shared-pooler hostname, `<role>.<project-ref>` as the user, and the PAT as the password. The password never goes in the URI — it must go through either `PGPASSWORD` or a non-URI conninfo. If the password is embedded in the URI as `.../?password=...`, the pooler will reject with 28P01 precisely because the password lands in the wrong place)Skip-scoped-PAT's için not: JIT şu an alpha; eğer scoped PAT kullanılıyorsa legacy (account-level) PAT ile A/B test yap. Alpha kanalı için zaman zaman Supavisor eşleme gecikmesi raporlanıyor (maddeye bakın: Supavisor circuit-breaker discussions). ### 2. Rule state: is the JIT rule actually active for the exact role+project? The dashboard "Temporary Access" preview shows the rule list. A rule that exists but is still in "propagating" state (Supavisor refresh) will fail auth until it converges — usually 5–15 seconds, occasionally minutes after a fresh PAT rotation. Disable → wait → re-enable and retest if the rule was just created. ### 3. IPv6/v4 and pooler mode Direct-vs-pooler matters: the shared pooler uses a single hostname and the password/credential is resolved against the Supavisor role map. When the pooler cannot see a credential for `supabase_read_only_user` it returns 28P01 before any `jit` config is applied. Double-check you're connecting through the **session-mode shared pooler** (`aws-0-<region>.pooler.supabase.com:5432`), not the direct connection (which is `:5432` on the project host, IPv6-preferred). ### Most useful next data point Since you've already isolated it to this role+token pair, the single most useful thing is a control experiment: connect with the SAME read-only user but using the **legacy account-level PAT** (not the scoped Database-JIT one). If that succeeds, the gap is scoped-PAT-in-JIT (alpha limitation). If it still 28P01s, it's role/pooler mapping, not token type — and the fix lives in Supavisor/infrastructure rather than in your credential rules. (For reference, the same distinct-answer pattern — scoped PAT accepted by Management API but rejected by Supavisor — is tracked in the Temporary Access alpha announcement thread. If you want, file the exact pair in that thread so the Supabase team can confirm whether scoped PATs are in-scope for JIT yet.)