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 - because RLS never belonged to the HTTP layer in the first place. The price is that the client becomes the claims authority, and that is a real security reassignment, not a config tweak.
Provenance. 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 doc’s design was run green live; three of its claims
needed correction from the measurements, 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. - 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 behavior 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/Kong incident classes (routing misconfigurations, PGRST303 JWT clock-skew rejections). You lose the Data API, GoTrue, Storage API enforcement, and Realtime.
The mechanism: RLS reads GUCs, not HTTP
Section titled “The mechanism: RLS reads GUCs, not HTTP”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 is a claims setter, not the enforcer. Any client that can run SQL can set the same GUC - which is both the opportunity and the hazard.
The claims form that auth.uid() resolves is the JSON GUC, measured in
this site’s lab (as postgres, which owns schema auth; a custom role
cannot call it directly - the grant caveat is in the pattern section):
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. - 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())// Worker / server, postgres.js driverconst 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 - and
this is now measured, not just principled: 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. Claims belong in
a transaction, never a bare SET.
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. There is no defense in depth left in the database layer - the JWT you verified in app code is the only thing standing between a caller and someone else’s rows. - Defense in depth moves into the DB schema where you can still have it. 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. Measured concretely (C02): a claims GUC carrying a
tampered sub (a valid uuid belonging to no user) is 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 behavior; this doc
is the third option 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 doc’s original warning was that two users running
the same deterministic SELECT * FROM pastes with different claims would
share one cache entry.
Measured on 2026-08-20 (supabase-lab C03): Hyperdrive did not replay across claims in the probe. 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 split-binding control below stays the documented pattern, because the behavior is not contractual and could change.
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 actually costs
Section titled “What leaving the HTTP layer actually 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 is also coherent -
and now measured: 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, and the
two Supabase pooler modes are now measured to differ on GUC hygiene:
session mode (5432) reset a bare
SETon return (no leak), transaction mode (6543) let a bareSETleak to the next invocation. The pattern needs transaction-scoped claims everywhere, but the failure mode for getting it wrong is worse on 6543.
What this buys during incidents
Section titled “What this buys during incidents”Two of the recurring classes in the incident resilience reference live entirely in the layer this pattern removes:
- PostgREST/Kong routing and config rollouts - regional misrouted-port and shared-reverse-proxy incidents (the class behind the 2026-08-14 us-east-2 503s) 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. You are removing one failure domain out of several - worth doing when you already own auth, not as a rewrite justified by a single outage.
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 GUC-is-unprivileged hazard, concrete |
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) |
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, nothing here beats it |
| 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 |
References
Section titled “References”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