← All posts
·6 min read·GuardLayer

Auth0 + Supabase RLS: the setup and the gotchas

SupabaseRLSAuth0AuthPostgres

TL;DR: Auth0 works with Supabase Row Level Security through Supabase's native third-party auth integration — but three things trip up almost everyone. (1) Auth0 silently strips custom claims from access tokens, so you must set claims on the ID token and send that to Supabase, not the access token. (2) Auth0 JWTs have no role claim by default, so without one every request runs as anon and your TO authenticated policies return nothing. (3) Auth0 user IDs (auth0|abc123) aren't UUIDs, so auth.uid() breaks — store the ID as text and compare auth.jwt() ->> 'sub'.

Why doesn't my Auth0 user work with Supabase RLS?

By default, Supabase doesn't trust a JWT that Auth0 issued — so your query runs unauthenticated, RLS denies it, and you get empty results or a 401. The fix is to register Auth0 as a third-party auth provider, which tells Supabase to accept Auth0's asymmetrically-signed JWTs the same way it accepts its own. (Auth0 uses RS256 with a kid header by default, which is exactly what Supabase requires, so there's nothing to change there.)

But registering the provider is only step one. Even once Supabase trusts the token, the token has to carry the right claims and reach the database intact — and that's where Auth0's quirks bite.

How do I set up Auth0 with Supabase RLS?

Three pieces: register the provider, fix the token's claims in Auth0, and send the right token from the client.

1. Register Auth0 in Supabase. Dashboard → Authentication → Third-Party Auth → Add provider → Auth0, and enter your tenant ID and region. For local dev/CI, mirror it in supabase/config.toml:

[auth.third_party.auth0]
enabled = true
tenant = "your-tenant-id"
tenant_region = "us"

2. Add the role claim with an Auth0 Action. Supabase requires a literal role claim set to authenticated, or PostgREST switches into the anon role and your TO authenticated policies never match. Add a post-login Action in Auth0:

exports.onExecutePostLogin = async (event, api) => {
  api.idToken.setCustomClaim('role', 'authenticated');
};

Note it sets the claim on the ID token (api.idToken), not the access token — which leads directly to the biggest gotcha.

Gotcha #1: send the ID token, not the access token

This is the Auth0-specific trap. Auth0 silently strips non-namespaced custom claims from access tokens. So if you add role: authenticated and then send Auth0's access token to Supabase, the role claim is gone by the time it arrives — and you're back to running as anon with no idea why.

The fix is to send the ID token, where custom claims survive. Build the Supabase client with the accessToken option returning the raw ID token:

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  {
    async accessToken() {
      // The raw ID token — where the custom `role` claim actually survives.
      return (await auth0.getIdTokenClaims())?.__raw ?? null;
    },
  }
);

Yes, the option is named accessToken, and yes, you feed it the ID token. That naming mismatch is exactly why this bug is so hard to spot.

Gotcha #2: Auth0 user IDs aren't UUIDs

Supabase's auth.uid() helper reads the sub claim and casts it to uuid. Auth0 subjects look like auth0|abc123 or google-oauth2|1005… — not UUIDs — so auth.uid() either throws invalid input syntax for type uuid or returns NULL. This is the same failure mode Clerk users hit; see why auth.uid() is null with Clerk for the identical root cause.

Don't fight it. Read the Auth0 ID as text, never cast to uuid:

auth.jwt() ->> 'sub'   -- returns 'auth0|abc123' as text

And don't model user_id as a uuid referencing auth.users — Auth0 users don't exist in that table.

The correct RLS policy pattern

Store the owner column as text, defaulted to the caller's Auth0 subject, and compare it in every policy — wrapped in a subquery so it's evaluated once per statement:

create table documents (
  id      bigint generated always as identity primary key,
  title   text not null,
  user_id text not null default auth.jwt() ->> 'sub'   -- Auth0 sub, text
);

alter table documents enable row level security;

create policy "Users manage their own documents"
on public.documents
for all
to authenticated
using      ( (select auth.jwt() ->> 'sub') = user_id )
with check ( (select auth.jwt() ->> 'sub') = user_id );

Three details that matter: text on both sides (no uuid cast), TO authenticated (which only matches because of the role claim from step 2), and the (select auth.jwt() ...) wrapping, which lets Postgres evaluate the claim once per statement instead of once per row — the same RLS performance win that applies to auth.uid(). If you're new to how INSERT/SELECT/UPDATE policies fit together, the complete guide to Supabase RLS walks through each.

The silent-empty trap: missing role claim

If your queries come back empty with no error, the JWT is almost certainly missing "role": "authenticated" — so the policy is being evaluated for the anon role and matches nothing. This happens when the Action didn't run, when you're sending the access token (gotcha #1), or when you're hitting the DB with the bare anon key. Confirm from the database side on a real request:

select auth.jwt() ->> 'role', auth.jwt() ->> 'sub';

You want authenticated and your auth0|… id. If role is null or the sub is missing, fix the token before touching the policy. (The Supabase SQL editor runs as a privileged role with no request JWT, so auth.jwt() is null there and tells you nothing — test through a real authenticated request.)

Where GuardLayer fits (and where it doesn't)

Be clear-eyed: GuardLayer is a static scanner. It doesn't validate your Auth0↔Supabase integration, read runtime JWT claims, or know whether the role claim survived the trip — so it can't tell you why RLS is returning empty. That's a runtime config problem, and this post is the fix.

Where it does help is the shortcut people reach for when they're stuck: disabling RLS, or writing a USING (true) / no-user-scope policy to "make Auth0 work." GuardLayer flags exactly those — RLS turned off on a table, over-permissive policies, and predicates that don't reference the user at all. Get Auth0 + RLS working correctly instead of removing the safety net.

FAQ

Why does auth.uid() throw "invalid input syntax for type uuid" with Auth0? auth.uid() casts the sub claim to uuid, and Auth0 IDs like auth0|abc123 aren't UUIDs. Use auth.jwt() ->> 'sub' as text against a text column instead.

My Auth0 queries return empty with no error — why? The JWT is missing "role": "authenticated". Add it with a post-login Auth0 Action via api.idToken.setCustomClaim('role', 'authenticated'), and make sure you're sending the ID token — Auth0 strips that claim from access tokens.

Do I send Auth0's access token or ID token to Supabase? The ID token. Auth0 silently drops non-namespaced custom claims (including role) from access tokens, so the access token arrives without the claim Supabase needs. Return getIdTokenClaims()?.__raw from the client's accessToken option.

Can I store the Auth0 user ID in a uuid column? No. Auth0 subjects are prefixed strings, not UUIDs. Use user_id text and compare auth.jwt() ->> 'sub'; never reference auth.users, which only holds Supabase Auth users.

Does this work the same for Firebase Auth or Cognito? The third-party-auth mechanism is the same, but each provider has its own claim quirks — see Firebase Auth + Supabase RLS for Firebase's version (its trap is that a new user's first token has no role claim). The universal rules hold across all of them: register the provider, ensure a role: authenticated claim, and match the string sub against a text column rather than casting to uuid.

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.

Keep reading