RLS without Supabase Auth: JWT claims over the wire
A recurring question after any PostgREST-layer incident: can you keep row-level security while dropping both Supabase Auth and the Data API, talking to the database over the wire protocol instead? Yes: Postgres enforces RLS, and the HTTP layer only supplies the claims. The cost is that your backend becomes the claims authority, which moves a security responsibility onto code you own. This extends Option 1 in Locking down Supabase: the database is the boundary, which puts the control on the row.
Measured end to end against throwaway Supabase projects and a probe Worker
on 2026-08-20 (supabase-lab experiments/rls-wire-claims, modules C01-C03).
The design ran green live, and the measurements corrected three of its
claims, marked below.
TL;DR:
- RLS is enforced by Postgres for any non-
BYPASSRLS, non-owner role on any connection - raw wire included. What the HTTP layer adds is the claims context: PostgREST validates the JWT and setsrequest.jwt.claimssoauth.uid()resolves. Nothing stops your own backend from setting the same GUC (grand unified configuration - Postgres’s run-time parameter system;request.jwt.claimsis one). - The pattern: verify your own JWT in app code, create a SECURITY DEFINER
wrapper for
auth.uid()(schemaauthis not grantable on managed Supabase), then per request runBEGIN; SELECT set_config('request.jwt.claims', '<claims>', true); <queries>; COMMIT;connecting as a dedicated non-superuser role. Per-user RLS policies work unchanged - measured end to end (supabase-labrls-wire-claims, 2026-08-20) on both Supavisor modes and through Hyperdrive. - Two sharp edges: the GUC is unprivileged (anyone with SQL access can set any claims, so the connection credential is now the security boundary), and any SQL-text-keyed query cache cannot see claims - Hyperdrive’s cache did not replay across claims in the measured probe, but the split-binding (cache-disabled for claims-scoped queries) stays the documented control because that behaviour is not contractual.1
- This works from any runtime with a Postgres driver, not just Cloudflare
Workers, and against any Postgres with RLS - the Supabase-specific part
is only the GUC name and the
auth.*helper functions. - You gain independence from the PostgREST/gateway incident classes (regional Data API 503s, PGRST303 JWT clock-skew rejections). You lose the Data API, GoTrue, Storage API enforcement, and Realtime.
RLS reads session GUCs
Section titled “RLS reads session GUCs”Postgres row-level security is a property of the table and the connecting
role.2 A policy like owner = auth.uid() is just SQL evaluated per
row, and on Supabase auth.uid() is a STABLE function that reads a session
configuration parameter - the sub claim out of the request.jwt.claims
GUC (JSON form), with auth.jwt() returning the whole JSON and
auth.role() reading its role claim.3
In the managed stack, the chain that populates that GUC is:
PostgREST sets the claims and Postgres enforces the policy. Any client that can run SQL can set the same GUC, which is what makes this pattern possible and what makes it hazardous.
The claims form that auth.uid() resolves is the JSON GUC. This was measured
in the lab as postgres, which owns schema auth; a custom role cannot call
it directly, for the grant reason in The pattern:
set "request.jwt.claims" = '{"sub":"<user-uuid>","role":"authenticated"}';select auth.uid(); -- returns <user-uuid>Policies that branch on auth.role() (for example
auth.role() = 'authenticated') need the role key present in the JSON -
over the wire the claim is just data; nothing maps it to the actual
connecting role the way PostgREST’s role switching does.
The pattern
Section titled “The pattern”- Run your own auth. Issue and verify JWTs yourself (or via an external provider). Verification happens in app code, before any SQL.
- Connect as a dedicated role - not
postgres, notservice_role(both carryBYPASSRLS), not the table owner. Grant it only the schemas, tables, and functions the app needs. RLS then enforces for every query it runs. Policies admit by grant target: the same predicate admitted 5,250 rowsTO publicand 0TO authenticated(rls-policy-cost E). A project’s existing policies are usuallyTO authenticated, and the wire role is not that role, so with RLS on and no policy naming it the role reads 0 rows and the symptom looks like broken claims. Auditpg_policies.rolesand write the wire-path policiesTO claims_user; making the role a member ofauthenticatedis the other route, not measured. - Set claims per transaction, then query. One measured prerequisite
first: on managed Supabase,
GRANT USAGE ON SCHEMA authto a custom role silently no-ops, so the policy cannot callauth.uid()directly -permission denied for schema authat runtime. Create a SECURITY DEFINER wrapper owned bypostgresand point the policy at it:
create or replace function public.claims_uid() returns uuid language sql stable security definer set search_path = auth, public as $$ select auth.uid() $$;grant execute on function public.claims_uid() to claims_user;-- policy: using (owner = public.claims_uid())Three notes on that block. Write the policy as
using (owner = (select public.claims_uid())): the subselect hoists the
helper to an InitPlan evaluated once per statement. In rls-policy-cost A,
bare auth.uid() took 252.9ms against 22.2ms wrapped on 100k unindexed rows
with identical row digests, and a sequence-counted PL/pgSQL stand-in was
called 100,001 times bare against 1 wrapped. The wrapper itself was not
timed, so its hoist is inferred from the same STABLE-function mechanism.
Postgres grants EXECUTE on a new function to PUBLIC by default, so add
revoke execute on function public.claims_uid() from public; before the
grant, or every role reaches auth.uid() through the wrapper. The Management
API security advisor lints exactly this shape as
anon_/authenticated_security_definer_function_executable and
function_search_path_mutable (security-lockdown S01). The wrapper above
already pins search_path; the revoke is standard Postgres and was not
measured here.
And if you provision through the Management API query endpoint
(POST /v1/projects/{ref}/database/query), it wraps a multi-statement body
in one transaction, so a policy that references the wrapper fails when both
are created in the same call - run the function first, the policy second.
// Worker / server, postgres.js driver// (Workers: postgres.js needs the nodejs_compat compatibility flag)const claims = JSON.stringify({ sub: userId, role: "authenticated" });const rows = await sql.begin(async (tx) => { // set_config(name, value, is_local=true) == SET LOCAL: scoped to this // transaction. Use set_config, not `SET LOCAL x = $1` - SET does not // accept bind parameters, so interpolating claims into SET text is a // SQL-injection surface. set_config parameterizes cleanly. await tx`SELECT set_config('request.jwt.claims', ${claims}, true)`; return tx`SELECT * FROM pastes`; // RLS sees auth.uid() = userId});SET LOCAL semantics: the setting holds until transaction end, then
reverts.4 Never use bare SET outside a transaction. On the
session pooler (5432) a bare SET was reset on return (no leak across 5
subsequent invocations), but on the transaction pooler (6543) a bare SET
leaked across invocations: the next psql session saw the first’s rows.
Sharp edge 1: the GUC is unprivileged
Section titled “Sharp edge 1: the GUC is unprivileged”request.jwt.claims is an ordinary configuration parameter. PostgREST’s
validation is what makes it trustworthy; the GUC itself accepts anything.
Two consequences:
- The database credential becomes the claims authority. Whoever holds
the connection string your backend uses can
SETthemselves into any user. Treat that credential as equivalent to a service key: server-side only, rotatable, never in a client bundle. Nothing in the database layer validates the claims themselves (C02, below) - the JWT you verified in app code is the only check on who the caller is. - Defence in depth moves into the schema. Prefer policies that combine the claims GUC with data invariants (tenant columns, membership tables) so a claims mistake degrades to a smaller blast radius than “any user as anyone”.
This is strictly worse isolation than the default stack, where a leaked
anon key can only do what RLS allows and the JWT is validated by
infrastructure you do not control. It is strictly better than the
alternative raw-SQL shape - a BYPASSRLS role with authorization purely
in app code, where a SQL-injection bug in one query has no policy layer
behind it at all. In C02, a claims GUC carrying a tampered sub (a valid
uuid belonging to no user) was enforced exactly as written: zero rows, no
error, no validation anywhere in the database. The
Cloudflare + Supabase architecture reference
covers that BYPASSRLS-by-role variant and its measured behaviour; this
pattern sits between the two.
Sharp edge 2: RLS-blind query caches
Section titled “Sharp edge 2: RLS-blind query caches”Any query cache keyed on SQL text + parameters cannot see session GUCs.
Hyperdrive’s cache is exactly that, and it does not invalidate on
writes.1 The risk is that two users running the same
deterministic SELECT * FROM pastes with different claims could share one cache
entry.
In the 2026-08-20 probe (supabase-lab C03), Hyperdrive did not replay across claims. An identical parameterized query under user A’s claims warmed the cache; the same SQL text and parameter under user B’s claims returned B’s filtered result (0 rows), not A’s cached row. Either the cache key includes claims-relevant session state or claims-carrying transactions are excluded from caching. Do not rely on this: the behaviour is not contractual and could change, so the split-binding control below stays the documented pattern.
The documented fix is the split-binding pattern: one Hyperdrive
configuration with caching for queries that are claims-independent
(reference data, public reads), a second created with
--caching-disabled for every query whose result depends on
request.jwt.claims - the same split Cloudflare already recommends for
auth, sessions, permissions, and read-after-write paths.1
When in doubt, a query is claims-dependent. The per-query SET; SELECT
multi-statement form is never cached, but do not rely on that as the
policy - the cache-disabled binding is the explicit control.
What leaving the HTTP layer costs
Section titled “What leaving the HTTP layer costs”The connection string is the same one Supabase documents for every external client; nothing here needs platform changes.6 What you stop getting is the rest of the product surface that rides HTTP:
| Surface | Over the wire | Leaving it means |
|---|---|---|
| PostgREST Data API | gone | every .from() call becomes SQL you write |
| GoTrue (Auth) | gone | you own signup, OAuth, magic links, token mint/refresh, revocation |
| Storage API | gone | signed URLs and storage.objects RLS are enforced by the Storage service, not by Postgres on a raw connection - object downloads need a new authz story |
| Realtime | gone | the WS layer authorizes by JWT; replicating it is its own project |
| Edge Functions | unaffected | separate runtime, still callable |
Keeping Supabase Auth while dropping only PostgREST also works. The lab’s
C02 created a real user via the Auth admin
API, signed it in via password grant, and set the claims from the GoTrue
JWT’s sub over the wire; per-user RLS resolved exactly as it would have
through PostgREST. Verify the GoTrue-issued JWT in your backend (JWKS,
cached - measured ~0ms per verify at the edge in the
architecture reference),
then set the claims it carried. That keeps GoTrue’s user management and
removes only the Data API.
Not just Cloudflare
Section titled “Not just Cloudflare”Nothing in the pattern is Workers-specific:
- Any runtime with a Postgres driver - Vercel functions, Lambda, Deno
Deploy, a VM - can run the same
set_configper transaction. The only runtime requirements are TCP to the database (direct5432, or the Supavisor pooler) and a transaction-scoped API.6 - Any Postgres with RLS, not just Supabase. On a plain Postgres (Neon,
RDS, self-hosted) there is no
authschema, so define your own convention -SET LOCAL app.user_id = ...read viacurrent_setting('app.user_id', true)in policies. The mechanism, hazards, and pooler rules are identical; only the GUC name changes. - Pooler compatibility is the one thing to check per platform. The two
Supabase pooler modes differ on bare-
SEThygiene (measured above), so the pattern needs transaction-scoped claims everywhere, and getting it wrong costs more on 6543.
What this changes during incidents
Section titled “What this changes during incidents”Two of the recurring classes in the incident resilience reference live entirely in the layer this pattern removes:
- PostgREST/gateway routing and config rollouts - regional incidents
where the Data API returns 503 while the database is healthy (a public
example: the 2026-08-14 us-east-2 incident, user-reported as PostgREST
503s with gateway connection-refused upstreams while
/auth/v1/healthstayed 2007) cannot affect a connection that never traverses them. - PGRST303 JWT clock-skew rejections - PostgREST validating
iat/expis gone; your verifier is the only clock that matters.
Everything below the HTTP layer is unchanged: compute lifecycle events, Supavisor failures (if you route through it), and the database itself still take you down. This removes one failure domain of several, which is worth doing when you already own auth; a single outage does not justify the rewrite.
What to do about it
Section titled “What to do about it”Each row names the module id or run it rests on (rls-wire-claims unless prefixed); a row that is a design choice rather than a result says so.
| Practice | Evidence | Module |
|---|---|---|
Write the policy as using (owner = (select public.claims_uid())). | The subselect hoists the helper to an InitPlan run once per statement - measured in rls-policy-cost A: auth.uid() bare 252.9ms against 22.2ms wrapped on 100k unindexed rows, identical row digests, and a sequence-counted PL/pgSQL stand-in called 100,001 times bare against 1 wrapped. The wrapper itself was not timed. | rls-policy-cost A |
Write wire-path policies TO claims_user and audit pg_policies.roles. | The same predicate admitted 5,250 rows TO public and 0 TO authenticated, so an existing TO authenticated policy set reads as 0 rows for the wire role. Membership in authenticated instead is not measured. | rls-policy-cost E |
Add revoke execute on function public.claims_uid() from public; before the grant, then run the Management API security advisor. | It lints anon_/authenticated_security_definer_function_executable and function_search_path_mutable. The default PUBLIC grant is standard Postgres, not measured here. | security-lockdown S01 |
| Warm the pooler with a local connection and retry before creating the Hyperdrive config. | On a fresh project the create-time connect check raced Supavisor warmup with ENOTFOUND tenant/user. | C03 |
Set the nodejs_compat compatibility flag on a Worker using postgres.js. | The probe Worker needed it. | C03 |
| Provision the wrapper and the policy in separate calls. | POST /v1/projects/{ref}/database/query wraps a multi-statement body in one transaction, and a policy referencing a function created in the same call fails. | C01-C03 fixture provisioning |
Measured results (2026-08-20)
Section titled “Measured results (2026-08-20)”The pattern above was run green against throwaway projects and a probe
Worker (supabase-lab experiments/rls-wire-claims, modules C01-C03,
deleted after):
| Claim | Result |
|---|---|
| Claims GUC drives per-user RLS as a custom role | pass - A sees A’s row, B sees B’s, no claims sees 0 (5432, 6543, Hyperdrive tx + multi) |
GRANT USAGE ON SCHEMA auth to a custom role | silently no-ops; auth.uid() then errors permission denied for schema auth. SECURITY DEFINER wrapper is the working shape |
GoTrue-issued sub over the wire | pass - per-user RLS resolves with no PostgREST anywhere |
| Tampered sub enforced as-is | pass - the unprivileged-GUC hazard in practice |
Session pooler (5432) bare-SET leak | no leak across 5 invocations (resets on return) |
Transaction pooler (6543) bare-SET leak | leaks - next invocation saw the first’s rows |
| Hyperdrive cache replays across claims | not observed - B’s claims got B’s (empty) result, not A’s cached row |
Hyperdrive bare SET | reset on pool return (0 rows on the next query) |
| Prepared statements over 6543 | pass (parity with the lab’s T11 finding, recorded in the privatelink-aws RUNLOG rather than as a module) |
Which do I pick
Section titled “Which do I pick”| Your situation | Pick |
|---|---|
| Standard app, Supabase Auth, SDK ergonomics matter | supabase-js over PostgREST - the default |
| Own auth already, want per-user RLS kept, edge/serverless runtime | claims over the wire (this doc), cache-disabled binding for user-scoped reads |
| Own auth, RLS is coarse (role-level, not per-user) | BYPASSRLS role + app authz, per the architecture reference - simpler, fewer GUC mechanics |
| Only the PostgREST layer is the problem, Auth is fine | GoTrue JWTs + claims over the wire - keeps user management, drops the Data API |
| Leaving Supabase entirely | same pattern on plain Postgres with app.* GUCs |
| Own the auth server too, on a managed project | a self-hosted GoTrue as postgres with search_path=auth, measured in Supabase Auth end to end |
Modules
Section titled “Modules”| Module | Experiment | Test | Artifact |
|---|---|---|---|
| C02 | rls-wire-claims | c02-gotrue-claims.ts | none published |
| C03 | rls-wire-claims | c03-hyperdrive-cache.ts | none published |
| S01 | security-lockdown | s01-security-advisors.ts | none published |
| T11 | privatelink-aws | RUNLOG | - |
References
Section titled “References”-
Cloudflare, “Query caching,” Cloudflare Docs. https://developers.cloudflare.com/hyperdrive/concepts/query-caching/ ↩ ↩2 ↩3
-
PostgreSQL Global Development Group, “Row Security Policies,” PostgreSQL Documentation. https://www.postgresql.org/docs/current/ddl-rowsecurity.html ↩
-
Supabase, “Row Level Security,” Supabase Docs. https://supabase.com/docs/guides/database/postgres/row-level-security ↩
-
PostgreSQL Global Development Group, “SET,” PostgreSQL Documentation. https://www.postgresql.org/docs/current/sql-set.html ↩
-
Cloudflare, “How Hyperdrive works,” Cloudflare Docs. https://developers.cloudflare.com/hyperdrive/concepts/how-hyperdrive-works/ ↩
-
Supabase, “Connecting to your database,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres ↩ ↩2
-
Supabase GitHub Issue #49097, “PostgREST remains unhealthy after project restart (500 internal server error),” 2026-08-14 - user-side evidence of the us-east-2 503s with
/auth/v1/healthgreen throughout. https://github.com/supabase/supabase/issues/49097 ↩