← All posts
·6 min read·GuardLayer

Firebase Auth + Supabase RLS: setup and gotchas

SupabaseRLSFirebaseAuthPostgres

TL;DR: Firebase Auth works with Supabase Row Level Security through Supabase's native third-party auth integration — but three things trip people up. (1) Firebase JWTs have no role claim by default, so add role: "authenticated" with the Admin SDK's setCustomUserClaims, or every query runs as anon. (2) That claim only lands on the next ID token, so a freshly signed-up user's first token lacks it — force-refresh the token after sign-up. (3) Firebase UIDs aren't UUIDs, so auth.uid() breaks — store the UID as text and compare auth.jwt() ->> 'sub'.

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

By default, Supabase won't trust a JWT that Firebase issued, so the request runs unauthenticated and RLS denies it. You fix that by registering Firebase as a third-party auth provider, which tells Supabase to accept Firebase's RS256-signed tokens. But even after Supabase trusts the token, Firebase tokens don't carry the claims Supabase needs — and Firebase's claim timing adds a trap the other providers don't have.

How do I set up Firebase Auth with Supabase RLS?

Three pieces: register the provider, add the role claim in Firebase, and send the ID token from the client.

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

[auth.third_party.firebase]
enabled = true
project_id = "your-firebase-project-id"

2. Add the role claim with the Admin SDK. Supabase needs a literal role: "authenticated" claim, or PostgREST switches into the anon role and your TO authenticated policies match nothing. Set it server-side (a Cloud Function on user creation, or a one-time backfill for existing users):

import { getAuth } from 'firebase-admin/auth';

await getAuth().setCustomUserClaims(userRecord.uid, {
  role: 'authenticated',
});

3. Send the ID token from the client. Build the Supabase client with the accessToken option returning Firebase's 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() {
      return (await firebase.auth().currentUser?.getIdToken()) ?? null;
    },
  }
);

Gotcha #1: the first token has no role claim

This is the Firebase-specific trap. setCustomUserClaims runs asynchronously and the claim only propagates to the ID token the next time one is issued — so the very first token a just-signed-up user holds does not contain role: "authenticated". Their first requests run as anon, RLS returns nothing, and it looks like your policies are broken when they're fine.

Force a token refresh right after sign-up (or after you set the claim) so the client picks up the new claim immediately:

// After sign-up / after the claim is set server-side:
await firebase.auth().currentUser?.getIdToken(true); // true = force refresh

Without that true, the user is stuck as anon until their token naturally rotates (up to an hour), which is a maddening "works for me, broken for new users" bug.

Gotcha #2: Firebase UIDs aren't UUIDs

Supabase's auth.uid() reads the sub claim and casts it to uuid. A Firebase UID is a 28-character string like aBcD1eFg2... — not a UUID — so auth.uid() throws invalid input syntax for type uuid or returns NULL. It's the same root cause Clerk and Auth0 users hit.

Read the UID as text, never cast to uuid:

auth.jwt() ->> 'sub'   -- returns the Firebase UID as text

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

The correct RLS policy pattern

Store the owner column as text, default it to the caller's Firebase UID, and compare it in every policy — wrapped in a subquery so Postgres evaluates it once per statement:

create table notes (
  id      bigint generated always as identity primary key,
  body    text not null,
  user_id text not null default auth.jwt() ->> 'sub'   -- Firebase UID, text
);

alter table notes enable row level security;

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

Three things 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 caches the claim per statement instead of re-reading it per row — the same RLS performance win that applies to auth.uid(). New to how the policies fit together? Start with the complete guide to Supabase RLS.

The silent-empty trap: missing role claim

If queries come back empty with no error, the JWT is missing "role": "authenticated" — so the policy runs for anon and matches nothing. On Firebase this is almost always gotcha #1 (the un-refreshed first token) or a user created before you started setting the claim. Confirm from the database side on a real request:

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

You want authenticated and the Firebase UID. If role is null, refresh the token or backfill the claim. (The Supabase SQL editor has no request JWT, so auth.jwt() is null there — test through a real authenticated request.)

One more, if you self-host: pin the project

Supabase validates Firebase tokens against your project, but if you self-host, write restrictive RLS on every public table, Storage bucket, and Realtime channel so a token from an unrelated Firebase project can't reach your data. The aud claim on a Firebase token is the project ID — the policies above already scope by sub, which keeps each user to their own rows regardless.

Where GuardLayer fits (and where it doesn't)

GuardLayer is a static scanner — it doesn't validate your Firebase↔Supabase integration or read runtime JWT claims, so it can't tell you why RLS is returning empty. That's a runtime config problem, and this post is the fix.

What it does catch is the shortcut people reach for when they're stuck: disabling RLS, or a USING (true) / no-user-scope policy to "make Firebase work." GuardLayer flags RLS turned off on a table, over-permissive policies, and predicates that don't reference the user at all — so you fix the integration instead of removing the safety net.

FAQ

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

My Firebase queries work for me but return empty for new users — why? Their first ID token was issued before the role: "authenticated" claim propagated. Call getIdToken(true) to force-refresh the token right after sign-up (or after setting the claim server-side).

How do I add the role claim to Firebase tokens? Server-side with the Admin SDK: getAuth().setCustomUserClaims(uid, { role: 'authenticated' }), ideally in a Cloud Function that runs on user creation, plus a one-time backfill for existing users.

Can I store the Firebase UID in a uuid column? No. Firebase UIDs are strings, not UUIDs. Use user_id text and compare auth.jwt() ->> 'sub'; never reference auth.users.

Does this work the same for Auth0 or Clerk? The third-party-auth mechanism is identical; the quirks differ. See Auth0 + Supabase RLS (where you must send the ID token, not the access token) and Clerk + Supabase RLS.

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