Clerk + Supabase RLS: why auth.uid() is null
TL;DR: When Clerk is your auth provider, auth.uid() breaks because Clerk user IDs (user_2abc…) are not UUIDs — auth.uid() casts the sub claim to uuid, so it either throws 22P02 invalid input syntax for type uuid or returns NULL. The fix is Supabase's native third-party-auth integration with Clerk, plus RLS policies that compare (select auth.jwt() ->> 'sub') to a text ownership column. Do not reach for the old JWT-template method (deprecated April 1, 2025), and do not disable RLS to "make Clerk work."
Why is auth.uid() null with Clerk?
Because auth.uid() is a thin helper that reads the sub claim out of the request JWT and casts it to uuid. Simplified, it's:
select nullif(current_setting('request.jwt.claims', true)::jsonb ->> 'sub', '')::uuid
The trailing ::uuid is load-bearing. Native Supabase users have a UUID sub, so the cast succeeds. Clerk user IDs are prefixed strings like user_2abc123XYZ — not valid UUIDs. That gives you two distinct failure modes, and people constantly conflate them:
- A real Clerk token is present →
sub = "user_2abc..."→'user_2abc...'::uuidfails → Postgres raises22P02: invalid input syntax for type uuid. The query errors; it does not silently return null. - No
subclaim at all (anon/unauthenticated) → thenullifyields NULL →NULL::uuid→auth.uid()returns NULL.
Either way, auth.uid() is the wrong tool with Clerk, and you must not model user_id as a uuid referencing auth.users — there are no rows in auth.users for Clerk users. This is the same root cause behind auth.uid() returning NULL in a Supabase RLS policy, just with a Clerk-specific twist: it's not a missing JWT, it's a JWT whose sub can't be cast.
The correct read is the Clerk ID as text, never cast to uuid:
auth.jwt() ->> 'sub' -- returns 'user_2abc...' as text
How do I use Clerk auth with Supabase RLS?
Two sides. Supabase registers Clerk as a trusted token issuer; Clerk turns the integration on and injects the required role claim. No shared secret changes hands — Supabase validates Clerk's session tokens against Clerk's published JWKS.
Clerk side (do this first):
- Open Clerk's Connect with Supabase page:
dashboard.clerk.com/setup/supabase. - Click Activate Supabase integration.
- Copy the revealed Clerk domain (e.g.
your-app.clerk.accounts.dev).
Supabase side:
- Dashboard → Authentication → Third-Party Auth (in newer dashboards, Sign In / Providers → Add provider).
- Add provider → Clerk, and paste the Clerk domain. For local dev/CI, mirror this with an
[auth.third_party.clerk]block insupabase/config.toml.
Supabase's APIs require the token to carry a "role": "authenticated" claim — that string is the Postgres role PostgREST switches into, and it's what TO authenticated policies match. On the recommended automatic path, the Clerk integration adds this claim to every session token it issues after activation. (Only if you can't use the Connect page do you add it manually via Sessions → Customize session token with {"role": "authenticated"}.)
The correct RLS policy pattern
Store the owner column as text, defaulted to the caller's Clerk ID from the JWT:
create table tasks (
id bigint generated always as identity primary key,
name text not null,
user_id text not null default auth.jwt() ->> 'sub' -- Clerk id, text
);
alter table tasks enable row level security;
create policy "Users can view their own tasks"
on public.tasks for select to authenticated
using ( (select auth.jwt() ->> 'sub') = user_id );
create policy "Users can insert their own tasks"
on public.tasks for insert to authenticated
with check ( (select auth.jwt() ->> 'sub') = user_id );
create policy "Users can update their own tasks"
on public.tasks for update to authenticated
using ( (select auth.jwt() ->> 'sub') = user_id )
with check ( (select auth.jwt() ->> 'sub') = user_id );
create policy "Users can delete their own tasks"
on public.tasks for delete to authenticated
using ( (select auth.jwt() ->> 'sub') = user_id );
Three details, all of which matter:
- The
(select auth.jwt() ...)subselect lets Postgres evaluate the claim once per statement (an initPlan) instead of once per row — Supabase benchmarks this at roughly 179 ms → 9 ms on a large table. See RLS performance. TO authenticatedis not cosmetic. It lets Postgres skip the policy entirely for other roles before touching any rows.- No uuid cast anywhere. Both sides are text — that's the whole point.
One server-side trap: a service_role insert has no request JWT, so auth.jwt() is NULL and the not null default produces a not-null violation. Set user_id explicitly in server code for those writes. (If a bad insert is your symptom, the new row violates row-level security policy guide walks through the WITH CHECK side.)
The role-claim gotcha: empty results, no error
TO authenticated matches only when the JWT carries "role": "authenticated". If that claim is absent, the policy silently never matches — you get zero rows, not an error. Everything "works," but every query comes back empty, so people blame their using expression when the real problem is the missing role.
You'll hit this if you're testing with a token minted before you activated the integration, you're on the legacy JWT-template path and forgot to add the role, or you're hitting the DB with the anon key (role anon). Confirm from the DB side with select auth.jwt() ->> 'role'; on a real request.
Client wiring (current TypeScript)
Create the Supabase client with the accessToken async option (supabase-js v2). On every request it fetches Clerk's live session token and sends it as Authorization: Bearer. You do not call supabase.auth.setSession, and you do not mix in supabase.auth.signInWith… on the same client.
import { useSession } from '@clerk/nextjs'
import { createClient } from '@supabase/supabase-js'
function createClerkSupabaseClient(session) {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{ async accessToken() { return session?.getToken() ?? null } },
)
}
Server side (App Router / server actions), pull the token from Clerk's server helper and pass the same option:
const { getToken } = await auth()
// createClient(url, key, { accessToken: async () => (await getToken()) ?? null })
Note there's no template argument — session.getToken(), not getToken({ template: 'supabase' }). That template call is the deprecated pattern.
The legacy JWT-template method is deprecated
The old approach — creating a Clerk JWT template named "Supabase" and pasting your project's Supabase JWT secret into Clerk so Clerk could mint HS256 tokens Supabase would trust — was deprecated as of April 1, 2025 (native integration announced March 31, 2025). It required handing Supabase's signing secret to a third party, which is exactly the security problem the native integration removes. If you're migrating: delete the "Supabase" JWT template from Clerk, register the Clerk domain under Third-Party Auth, and switch the client to the accessToken option above.
The unsafeMetadata security pitfall
Clerk has three metadata buckets, and only one is writable from the browser:
| Field | Read | Write |
|---|---|---|
publicMetadata | frontend + backend | backend only |
privateMetadata | backend only | backend only |
unsafeMetadata | frontend + backend | frontend + backend |
unsafeMetadata is writable by the signed-in user directly from the frontend:
// The END USER can run this against their own session:
await user.update({ unsafeMetadata: { role: 'admin', plan: 'enterprise' } })
If any authorization decision — a role check, an entitlement gate, or an RLS custom claim — trusts unsafeMetadata, any authenticated user can self-promote to admin or unlock paid tiers. Store roles in publicMetadata (written server-side via the Clerk Backend API) or privateMetadata, and verify server-side.
Clerk metadata isn't in the session token by default, so to gate RLS on a role you surface publicMetadata as a custom claim in Clerk's session-token settings — never unsafeMetadata:
{ "user_role": "{{user.public_metadata.role}}" }
create policy "Admins read all tasks"
on public.tasks for select to authenticated
using ( (select auth.jwt() ->> 'user_role') = 'admin' );
Templating {{user.unsafe_metadata.role}} here would make the RLS check attacker-controlled — the same footgun, one layer deeper.
How to verify the claims
The reliable signal is the actual JWT the database receives. Decode it and confirm both sub and role:
const token = await session?.getToken()
console.log(JSON.parse(atob(token!.split('.')[1])))
// want: { sub: "user_2abc...", role: "authenticated", iss: "https://<you>.clerk.accounts.dev" }
The Supabase SQL editor runs as the service role with no request claims, so select auth.jwt(); there returns NULL and tells you nothing. To unit-test a policy locally, simulate a request:
select set_config('request.jwt.claims',
'{"sub":"user_2abc...","role":"authenticated"}', true);
select (select auth.jwt() ->> 'sub') = 'user_2abc...'; -- your policy predicate
Or expose a debug RPC and call it through PostgREST as the real user, so the claims come from the verified Clerk token:
create or replace function public.whoami()
returns jsonb language sql stable as $$ select auth.jwt(); $$;
Where GuardLayer fits (and where it doesn't)
Be straight about this: GuardLayer is a static scanner. It reads your SQL and code — it does not validate your Clerk↔Supabase integration, read runtime JWT claims, or know whether the role claim is present. So it cannot tell you why auth.uid() is null with Clerk. That's a runtime config problem, and this post is the fix.
Where it does help is the failure mode people reach for when they're stuck: disabling RLS, or writing a USING (true) / no-user-scope policy to "make Clerk work." GuardLayer flags exactly those — RLS missing on a table, RLS explicitly disabled, a USING (true) policy, and policies with no user-scoping predicate. The goal is to get Clerk + RLS working correctly instead of removing the safety net. If you got here after giving up and turning RLS off, read why you should never disable RLS to fix an error first.
FAQ
Why does auth.uid() throw "invalid input syntax for type uuid" with Clerk?
Because auth.uid() casts the sub claim to uuid, and Clerk IDs like user_2abc… aren't UUIDs. Use auth.jwt() ->> 'sub' as text against a text column instead.
Do I still need a Clerk JWT template for Supabase?
No. The "Supabase" JWT template and shared JWT secret were deprecated on April 1, 2025. Use the native Third-Party Auth integration and the accessToken client option.
My Clerk queries return empty with no error — why?
The JWT is missing "role": "authenticated". This happens with pre-activation tokens, the anon key, or a legacy template. Activate the native integration and re-mint the token; confirm with select auth.jwt() ->> 'role';.
Can I store the Clerk user ID in a uuid column?
No. Clerk IDs are strings, not UUIDs. Use user_id text and never reference auth.users — Clerk users don't exist there.
Is it safe to put roles in Clerk's unsafeMetadata for RLS?
No. unsafeMetadata is writable by the end user from the browser, so any user could grant themselves admin. Use publicMetadata (written server-side) and surface it as a custom claim.
Catch this before it ships — free
GuardLayer scans every push for this and 23 other Next.js + Supabase issues, with the exact fix inline.
No signup, no card — your code is scanned in memory and never stored.