WorkOS and Supabase RLS: fixing the auth.uid() trap for prefixed user ids
TL;DR: WorkOS is one of Supabase's five natively-supported third-party auth providers, so you use the clean native flow: register WorkOS as a trusted token issuer in the Supabase dashboard, build your supabase-js client with an accessToken() callback that returns the WorkOS token, and let RLS read verified claims via auth.jwt(). The one twist that breaks most setups: WorkOS user ids are prefixed strings like user_01H..., not UUIDs. So you must not use auth.uid() — store the owner as a text column and compare (select auth.jwt() ->> 'sub') = user_id. Text on both sides, no UUID cast anywhere.
Does WorkOS use the native Supabase integration or a workaround?
Native. Supabase supports exactly five third-party auth providers: Clerk, Auth0, Firebase Auth, AWS Cognito, and WorkOS. Because WorkOS (its hosted auth product is branded AuthKit) is on that list, it uses the same native pattern as the Clerk, Auth0, and Firebase walkthroughs — three moving parts:
- Register the issuer. In the Supabase dashboard under Authentication > Third-Party Auth, add WorkOS as a trusted token issuer.
- Wire the client. Build your supabase-js v2 client with an async
accessToken()callback that returns the current WorkOS-issued token on every request. - Read claims in RLS. Write policies that check the verified JWT via
auth.jwt().
This is emphatically not the better-auth/NextAuth server-side set_config('request.jwt.claims', ...) workaround, and you should never sign tokens with the Supabase JWT secret. WorkOS is supported natively, so none of that applies.
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
accessToken: async () => {
// Return the current WorkOS AuthKit token for this session.
// Confirm the exact SDK call against current WorkOS docs.
return await getWorkOsAccessToken()
},
})
Why does auth.uid() break with WorkOS?
Because WorkOS user ids are prefixed strings — user_01H..., the same shape family as Clerk ids — and auth.uid() assumes a UUID. Under the hood it does roughly:
nullif(current_setting('request.jwt.claims', true)::jsonb ->> 'sub', '')::uuid
It reads the sub claim and casts it to uuid. There are two distinct failure modes, and it matters that you keep them straight:
- With a real WorkOS token present:
'user_01H...'::uuidfails and Postgres raises22P02 invalid input syntax for type uuid. The query errors — it does not silently return null. - With no
subclaim at all: the cast never happens andauth.uid()returnsNULL.
This is exactly where WorkOS is the mirror image of its sibling: in the Cognito post, the sub is UUID-shaped, so auth.uid() can work. WorkOS's sub is a string, so a text column is mandatory. (For a deeper look at the null case, see why auth.uid() returns null in RLS.)
What is the correct WorkOS RLS pattern?
Store the owner as text, keep RLS on, and compare text to text:
create table documents (
id uuid primary key default gen_random_uuid(),
user_id text not null default (auth.jwt() ->> 'sub'),
body text
);
alter table documents enable row level security;
create policy "owner can read"
on documents for select
to authenticated
using ( (select auth.jwt() ->> 'sub') = user_id );
create policy "owner can insert"
on documents for insert
to authenticated
with check ( (select auth.jwt() ->> 'sub') = user_id );
Note what is not here: no uuid cast, and user_id is not a foreign key to auth.users. WorkOS users do not exist in the auth.users table, so modeling user_id as a UUID referencing it is wrong from the start. If you want the full RLS foundation, the complete row-level-security guide covers the primitives.
Why do my queries come back empty with no error?
This is the silent-empty trap, and it is a runtime/config problem — not a SQL bug. Every Supabase third-party token must carry "role": "authenticated". If it doesn't, PostgREST runs as the anon Postgres role, every to authenticated policy matches nothing, and your queries return empty with no error.
Ensure the WorkOS token carries role: authenticated (configure this in WorkOS — confirm the exact mechanism against current WorkOS/AuthKit docs rather than assuming it is auto-injected). Then verify from the database side on a real authenticated request:
select auth.jwt() ->> 'role', auth.jwt() ->> 'sub';
Do not run that in the Supabase SQL editor and trust the result. The SQL editor executes as a privileged role with no request JWT, so auth.jwt() is NULL there and tells you nothing. To simulate a request, set the claims first:
select set_config(
'request.jwt.claims',
'{"sub":"user_01H...","role":"authenticated"}',
true
);
-- now evaluate your policy predicate
Full mechanics are in how to test Supabase RLS, and if you are seeing 401 invalid claim, see the dedicated fix.
Why wrap the claim in (select ...)?
Performance. Writing (select auth.jwt() ->> 'sub') turns the claim lookup into an uncorrelated subquery that Postgres plans as an InitPlan — evaluated once per statement instead of once per row. Supabase's published 100K-row benchmark shows the pattern dropping from 179 ms to 9 ms after this wrap. Also add to authenticated (so the policy is skipped for anon entirely) and index the user_id column. The RLS performance post goes deeper.
Three traps that make WorkOS "work" by breaking security
-
The disable-RLS /
USING (true)fix. When RLS returns empty, the tempting move isalter table ... disable row level securityor writingusing (true). Both make the table readable through the anon key. Keep RLS on and add a correct policy — never remove the safety net. See why disabling RLS is dangerous and the USING (true) trap. -
service_role bypasses RLS entirely. The
service_rolekey (and the table owner /postgres) ignore your policies completely. Any query run with the service-role key is unfiltered — that is by design, so keep it server-side only. -
service_role insert + not-null default. A service_role insert carries no request JWT, so
auth.jwt()isNULL, and auser_id text not null default (auth.jwt() ->> 'sub')column throws a not-null violation. Setuser_idexplicitly in your server code for those writes. If you hitnew row violates row-level security policy, the dedicated post breaks it down.
The 2026 Data-API grant change matters here
Supabase changelog #45329 removed automatic Data/GraphQL API exposure for new tables. Tables now reach the auto-generated API only via explicit GRANTs to anon/authenticated. New projects default to this since 2026-05-30; existing projects flip on 2026-10-30 (so as of this writing, existing projects have not flipped yet). The implication: an explicit GRANT ... TO anon on a table with no RLS is now a deliberate public re-exposure, not an accident of the old blanket grant. Details in the Data-API grant change post.
Where GuardLayer fits (and where it doesn't)
GuardLayer is a static scanner — it reads your SQL migrations and code, not your running system. That boundary is important for WorkOS.
It cannot tell you why RLS returns empty, read your runtime JWT claims, confirm the role: authenticated claim is present, or validate the WorkOS↔Supabase integration end to end. Those are runtime/config problems this post fixes.
It can flag static issues: RLS disabled or missing on a table, USING (true) and over-permissive policies, policies with no user-scoping predicate (not user-scoped), hardcoded secrets or JWTs, and — via its supabase/grant-to-anon rule (CWE-732, severity: warning) — a public table GRANTed to the anon role when RLS is never enabled for it in the same migration.
FAQ
Can I use auth.uid() with WorkOS if I just cast carefully?
No. auth.uid() casts the sub claim to uuid, and a WorkOS sub like user_01H... is not a UUID — the cast raises 22P02. Compare auth.jwt() ->> 'sub' (text) against a text user_id column instead. Only Cognito, whose sub is UUID-shaped, can use auth.uid().
Should user_id reference auth.users?
No. WorkOS users live in WorkOS, not in Supabase's auth.users table, so a foreign key there is invalid. Model user_id as plain text holding the WorkOS sub.
Why do queries work in the SQL editor but fail in my app (or vice versa)?
The SQL editor runs as a privileged role with no request JWT, so auth.jwt() is NULL and RLS behaves differently than a real request. Always test through an authenticated request, or simulate claims with set_config('request.jwt.claims', ...).
My WorkOS query returns an empty array with no error — what's wrong?
Almost always the token is missing "role": "authenticated", so PostgREST falls back to the anon role and your to authenticated policies match nothing. Confirm the claim from the DB side with select auth.jwt() ->> 'role' on a real request.
Is sharing the Supabase JWT secret a valid WorkOS setup? Never. WorkOS is natively supported, so you register it as a trusted issuer and let Supabase verify its tokens. Signing tokens with the Supabase JWT secret is the unsupported workaround pattern and a security risk — see the better-auth contrast.
Does GuardLayer catch a missing role claim?
No. That is a runtime issue GuardLayer cannot see. It catches static SQL/code problems — disabled or missing RLS, USING (true), non-user-scoped policies, grant-to-anon, and hardcoded secrets.
Catch this before it ships — free
GuardLayer scans every push for this and 24 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.