Skip to content

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 sets request.jwt.claims so auth.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() (schema auth is not grantable on managed Supabase), then per request run BEGIN; 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-lab rls-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.

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:

Supabase HTTP layer (the part you are leaving)Your backend (the replacement)ClientKong gatewayHTTPS + JWTPostgRESTvalidates JWT,sets request.jwt.claimsPostgresRLS reads the GUCSQL + claims GUCWorker / serververifies own JWTwire protocol + set_config(...)

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.

  1. Run your own auth. Issue and verify JWTs yourself (or via an external provider). Verification happens in app code, before any SQL.
  2. Connect as a dedicated role - not postgres, not service_role (both carry BYPASSRLS), not the table owner. Grant it only the schemas, tables, and functions the app needs. RLS then enforces for every query it runs.
  3. Set claims per transaction, then query. One measured prerequisite first: on managed Supabase, GRANT USAGE ON SCHEMA auth to a custom role silently no-ops, so the policy cannot call auth.uid() directly - permission denied for schema auth at runtime. Create a SECURITY DEFINER wrapper owned by postgres and 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 driver
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 - 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.

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

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:

SurfaceOver the wireLeaving it means
PostgREST Data APIgoneevery .from() call becomes SQL you write
GoTrue (Auth)goneyou own signup, OAuth, magic links, token mint/refresh, revocation
Storage APIgonesigned 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
Realtimegonethe WS layer authorizes by JWT; replicating it is its own project
Edge Functionsunaffectedseparate 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.

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_config per transaction. The only runtime requirements are TCP to the database (direct 5432, 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 auth schema, so define your own convention - SET LOCAL app.user_id = ... read via current_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 SET on return (no leak), transaction mode (6543) let a bare SET leak to the next invocation. The pattern needs transaction-scoped claims everywhere, but the failure mode for getting it wrong is worse on 6543.

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/exp is 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.

The pattern above was run green against throwaway projects and a probe Worker (supabase-lab experiments/rls-wire-claims, modules C01-C03, deleted after):

ClaimResult
Claims GUC drives per-user RLS as a custom rolepass - 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 rolesilently no-ops; auth.uid() then errors permission denied for schema auth. SECURITY DEFINER wrapper is the working shape
GoTrue-issued sub over the wirepass - per-user RLS resolves with no PostgREST anywhere
Tampered sub enforced as-ispass - the GUC-is-unprivileged hazard, concrete
Session pooler (5432) bare-SET leakno leak across 5 invocations (resets on return)
Transaction pooler (6543) bare-SET leakleaks - next invocation saw the first’s rows
Hyperdrive cache replays across claimsnot observed - B’s claims got B’s (empty) result, not A’s cached row
Hyperdrive bare SETreset on pool return (0 rows on the next query)
Prepared statements over 6543pass (parity with the lab’s T11)
Your situationPick
Standard app, Supabase Auth, SDK ergonomics mattersupabase-js over PostgREST - the default, nothing here beats it
Own auth already, want per-user RLS kept, edge/serverless runtimeclaims 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 fineGoTrue JWTs + claims over the wire - keeps user management, drops the Data API
Leaving Supabase entirelysame pattern on plain Postgres with app.* GUCs
  1. Cloudflare, “Query caching,” Cloudflare Docs. https://developers.cloudflare.com/hyperdrive/concepts/query-caching/ 2 3

  2. PostgreSQL Global Development Group, “Row Security Policies,” PostgreSQL Documentation. https://www.postgresql.org/docs/current/ddl-rowsecurity.html

  3. Supabase, “Row Level Security,” Supabase Docs. https://supabase.com/docs/guides/database/postgres/row-level-security

  4. PostgreSQL Global Development Group, “SET,” PostgreSQL Documentation. https://www.postgresql.org/docs/current/sql-set.html

  5. Cloudflare, “How Hyperdrive works,” Cloudflare Docs. https://developers.cloudflare.com/hyperdrive/concepts/how-hyperdrive-works/

  6. Supabase, “Connecting to your database,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres 2