Trending Posts

Clerk Supabase RLS migration architecture diagram for multi-tenant SaaS

You wire up Clerk's Third-Party Auth integration, redeploy, and the first authenticated query against your Supabase database comes back empty. Not an error, not a 401. Just zero rows and a 200 OK, like the table quietly emptied itself overnight.

If you've migrated a live multi-tenant B2B SaaS application from Supabase Auth to Clerk, you've probably hit this, or you're about to. Every tutorial ranking for "Clerk Supabase RLS" walks you through wiring Clerk into a brand-new, empty database. None of them tell you what happens when your app already has real customers, bcrypt password hashes, and foreign keys pointing at auth.users.id as a UUID.

This guide covers the part the greenfield tutorials skip: migrating existing users without forcing a password reset, fixing the UUID-versus-string identifier mismatch in your schema, writing hoisted RLS policies against Clerk's organization claims, and running a zero-downtime dual-auth cutover with an actual rollback plan.

It builds on the tenant-isolation decisions covered in our complete guide to multi-tenant database design in Supabase. If you haven't settled on a schema strategy yet, start there first. Everything below assumes you're running Postgres RLS in production for paying customers, not prototyping a weekend side project.

Quick Answer: A Clerk Supabase RLS migration means rewriting policies from auth.uid() to (select auth.jwt()->>'sub'), since Clerk's user IDs aren't UUIDs and the auth.uid() cast fails. You'll also need Clerk's Backend API (externalId plus passwordDigest) to preserve bcrypt or argon2 password hashes so existing users skip a forced reset. Supabase deprecated the old HS256 JWT template integration on April 1, 2025, so a production cutover needs a temporary dual-auth bridge before you fully commit to the RS256/JWKS flow.

Table of Contents

  1. How Clerk Replaces Supabase Auth: The Architecture Shift
  2. Why Does auth.uid() Return NULL After Switching to Clerk?
  3. Migrating Existing Users and Password Hashes Without Forcing Resets
  4. Solving the UUID vs String Identifier Mismatch
  5. Writing Hoisted RLS Policies for Clerk's Organization Claims
  6. Why Does RLS Silently Return Empty Results on Expired Tokens?
  7. Handling Clerk Tokens in Next.js SSR and Supabase Edge Functions
  8. Keeping Users and Organizations Synced With Clerk Webhooks
  9. Zero-Downtime Cutover: Dual-Auth Bridge and Rollback Plan
  10. Quick Answers About Clerk Supabase RLS Migration
  11. Frequently Asked Questions

How Clerk Replaces Supabase Auth: The Architecture Shift

Clerk plugs into Supabase as a third-party OIDC provider, not a drop-in replacement for the Supabase client. Your Postgres database still enforces every RLS policy exactly as before. Only where the identity claims come from changes.

Simply put, Supabase now verifies Clerk-issued JWTs directly against Clerk's own JSON Web Key Set (JWKS) using asymmetric RS256 signatures, instead of trusting a shared secret. That's a deliberate security upgrade: the old integration required pasting your Supabase JWT secret into Clerk's dashboard, which meant a JWT secret rotation on your side could break Clerk's ability to mint valid tokens, and a Clerk-side breach could forge Supabase tokens for your project.

Setup has three parts. First, activate the Supabase integration from Clerk's dashboard, which reveals your Clerk domain. Second, register that domain in Supabase, either through the dashboard under Authentication > Sign In / Providers, or locally through supabase/config.toml:

# supabase/config.toml (local development or self-hosting)
[auth.third_party.clerk]
enabled = true
domain = "your-app.clerk.accounts.dev"

Third, every Clerk session token needs a role claim set to authenticated, since Supabase's API layer rejects requests without it. Activating the integration adds this automatically; if you're configuring things manually, you add it yourself when customizing Clerk's session token.

One more date worth knowing: projects still running the older JWT-template integration are exempted from Third-Party Monthly Active User billing until at least January 1, 2026, but that's a grace period, not a long-term plan. None of this touches your existing rows. It changes how Postgres decides which rows a request is allowed to see, which is exactly why the RLS layer, not the client setup, is where the real migration work happens.

Why Does auth.uid() Return NULL After Switching to Clerk?

Because auth.uid() assumes the JWT's sub claim is a valid UUID, and Clerk's aren't. This is the single most common complaint across Clerk-and-Supabase threads, and it trips up experienced engineers just as often as beginners.

Supabase's auth.uid() helper is effectively shorthand for casting the token's sub claim to uuid. That works fine when Supabase's own auth system issues the token, because Supabase always generates UUID user IDs. Clerk doesn't. Clerk's subject identifiers look like user_2abC9xYzPqRst: a text string, never a UUID. Feed that into an implicit UUID cast and you get NULL back, or an outright cast error depending on where it's evaluated.

The fix is to stop using auth.uid() once Clerk is your identity provider, and read the subject claim directly instead:

-- Before: relies on Supabase's own auth.users, breaks with Clerk
create policy "select_own_row"
on public.documents
for select
to authenticated
using (owner_id = auth.uid());

-- After: reads the Clerk subject straight from the verified JWT
-- (owner_id needs to become text, not uuid — see the identifier
-- mismatch section below for how to handle that column change)
create policy "select_own_row"
on public.documents
for select
to authenticated
using (owner_id = (select auth.jwt()->>'sub'));

Every RLS policy that still calls auth.uid() after the cutover is a policy that silently stops working. Grep your migrations for it before you flip the switch, not after a customer reports missing data.

Migrating Existing Users and Password Hashes Without Forcing Resets

Export your existing password hashes and re-import them through Clerk's Backend API, don't make every customer set a new password. Forcing a password reset across your entire user base during an auth migration is how a backend refactor turns into a support incident.

Start by pulling the fields you need out of Supabase's internal auth.users table:

-- Export existing identities before touching auth.users directly
select
  id,
  email,
  encrypted_password,
  raw_user_meta_data
from auth.users;

Then feed each row into Clerk's createUser() method, using passwordDigest and passwordHasher instead of a plaintext password:

import { createClerkClient } from '@clerk/backend'

const clerkClient = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY })

async function migrateUser(row: { id: string; email: string; encrypted_password: string }) {
  return clerkClient.users.createUser({
    emailAddress: [row.email],
    passwordDigest: row.encrypted_password,
    passwordHasher: 'bcrypt',  // match whatever Supabase Auth hashed with
    externalId: row.id,        // preserves the Supabase UUID for rollback and shadow mapping
    skipLegalChecks: true,     // Clerk's own migration guidance recommends this for bulk imports
  })
}

Clerk supports bcrypt, argon2, scrypt, and several PBKDF2 variants as passwordHasher values, so whatever Supabase used under the hood, there's almost certainly a matching option. The externalId field matters more than it looks: it's what lets you map every Clerk user back to their original Supabase UUID later, both for the schema migration in the next section and for the rollback plan near the end of this guide.

One practical constraint: Clerk's createUser() endpoint is rate-limited to 1,000 requests per 10 seconds on production instances, 100 on dev. For a user base in the tens of thousands, queue the import with backoff rather than firing every row at once.

If your SaaS handles EU customer data, loop in whoever owns your GDPR data processing agreements before this export happens. If you're pursuing SOC 2, this migration needs to show up in your change log, not just your terminal history.

Solving the UUID vs String Identifier Mismatch

You have two options: keep your UUID foreign keys and add a mapping table, or convert the columns to text and migrate the values. Neither is free, and the right call depends on how deep Clerk's IDs need to reach into your schema.

Shadow mapping leaves every existing foreign key untouched. You add one small table that translates between the two identity systems:

-- Shadow mapping: keeps existing UUID foreign keys exactly as they are
create table public.user_identities (
  supabase_uuid uuid primary key references auth.users(id),
  clerk_user_id text not null unique
);

RLS policies and application joins go through this table instead of comparing Clerk's sub claim directly against a UUID column. It's the safer option for a large schema with dozens of foreign keys, because you're adding a table, not touching existing ones.

Direct column refactoring is more invasive but cleaner long-term: alter every user_id-style column from uuid to text, and backfill the values to match Clerk's identifiers.

-- Direct refactor: convert the column, then backfill from the mapping table
alter table public.documents
  alter column owner_id type text
  using owner_id::text;

update public.documents d
set owner_id = ui.clerk_user_id
from public.user_identities ui
where d.owner_id = ui.supabase_uuid::text;

This removes the translation layer permanently, but every index, foreign key constraint, and downstream query referencing that column needs to be checked before you run it against production data. On a table with millions of rows, that migration also needs to run online, not as a blocking ALTER TABLE during business hours.

Most teams start with shadow mapping to get the migration shipped, then refactor specific hot-path tables later once things settle. Trying to do both, on every table, in one release, is how migrations slip by a quarter.


Architecture diagram comparing shadow mapping versus direct column refactoring for UUIDs in Postgres.

Writing Hoisted RLS Policies for Clerk's Organization Claims

Wrap every auth.jwt() call in a subquery, or Postgres re-evaluates it on every single row. This is the one detail every greenfield Clerk tutorial gets wrong, and it's the difference between a policy that scales and one that falls over under real traffic.

Written directly, auth.jwt()->>'sub' forces the query planner to re-run the JSON extraction for every row a scan touches. Wrapped in a scalar subquery, (select auth.jwt()->>'sub'), Postgres treats it as stable within the query and evaluates it once. On a multi-tenant table with real row counts, that's the difference between an index-friendly plan and a sequential scan that gets slower every quarter as the table grows. We cover the query-planner mechanics behind this in more depth in our guide to RLS query optimization.

Clerk's organization data shows up in your session token as org_id, org_role, and org_permissions claims, or nested under an o object as o.id and o.rol, depending on how your session token is configured. A read policy scoped to the caller's organization looks like this:

-- Hoisted subquery: the planner evaluates auth.jwt() once per query, not once per row
create policy "tenant_read"
on public.invoices
for select
to authenticated
using (
  tenant_id = (select coalesce(auth.jwt()->>'org_id', auth.jwt()->'o'->>'id'))
);

Write paths need the same boundary enforced separately, with with check instead of using:

-- with check enforces the tenant boundary on inserts, not just reads
create policy "tenant_write"
on public.invoices
for insert
to authenticated
with check (
  tenant_id = (select coalesce(auth.jwt()->>'org_id', auth.jwt()->'o'->>'id'))
  and (select coalesce(auth.jwt()->>'org_role', auth.jwt()->'o'->>'rol')) = 'org:admin'
);

Skip that with check clause and the gap looks fine in every manual test you run against your own account. It only shows up once someone probes another tenant's insert path, which is a bad way to find out.

Why Does RLS Silently Return Empty Results on Expired Tokens?

Because an invalid or expired JWT makes auth.jwt() evaluate to NULL, and a policy comparing against NULL simply excludes every row instead of throwing an error. Postgres runs the query successfully. It just finds nothing that satisfies the policy, and returns an empty result set with a normal 200 response.

That's dangerous specifically because it looks identical to a legitimate empty state. A dashboard showing zero invoices could mean the tenant genuinely has none, or it could mean their Clerk session expired thirty seconds ago and every request since has been silently failing closed. Nothing in the response tells you which one you're looking at.

A few defensive habits fix this:

  • Check token validity client-side before treating an empty response as real data, not after.
  • Log and alert on queries that unexpectedly return zero rows for a tenant with a known non-zero row count.
  • Keep Clerk session token lifetimes short enough that stale organization claims don't linger for hours after a permission change.

That last point matters beyond expiry. If a user gets removed from a Clerk organization, their existing session token still carries the old org_id and org_role claims until it naturally expires. Clerk doesn't retroactively invalidate tokens already issued. For anything security-sensitive, don't rely on the token alone; add a server-side check against Clerk's API for mutations that really matter, rather than trusting a claim that could be minutes or hours stale.

Silent failure is the RLS default, not a bug. Design your client and your monitoring around that fact instead of discovering it from a confused support ticket.

Handling Clerk Tokens in Next.js SSR and Supabase Edge Functions

Server Components and Server Actions need the token pulled server-side through Clerk's own helper, not a browser-oriented Supabase auth package. Older Supabase auth-helper patterns built around reading cookies client-side don't line up with how Clerk manages sessions.

For the Next.js App Router, extract the token through @clerk/nextjs/server and hand it to the Supabase client the same way you would on the client, just from a server context:

// lib/supabase/server.ts
import { auth } from '@clerk/nextjs/server'
import { createClient } from '@supabase/supabase-js'

export function createServerSupabaseClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      accessToken: async () => (await auth()).getToken(),
    },
  )
}

Supabase Edge Functions are a separate problem. The platform-level gateway check that runs before your function code executes doesn't resolve third-party JWKS endpoints, so a Clerk-issued RS256 token fails that check even though it's perfectly valid. The current recommended approach is to deploy the function without that gate and verify the token yourself, inside the handler:

# Edge Functions don't resolve third-party JWKS at the platform gateway yet,
# so deploy without the built-in check and verify the token inside the handler
supabase functions deploy my-function --no-verify-jwt

Inside the handler, verify the token against your Clerk domain's JWKS endpoint before trusting anything in it. Supabase publishes an official custom-JWT-validation example with a ready-made Clerk template for exactly this, so you're not writing a JWKS verifier from scratch.

Skipping this step and leaving the platform gateway check enabled doesn't fail safely, it fails loudly and immediately. Skipping the manual verification after disabling that gate fails silently, which is worse. Do both steps, not just the first one.

Keeping Users and Organizations Synced With Clerk Webhooks

Supabase's Clerk integration authenticates requests, it doesn't copy user or organization data into your database. If you need to join against a user's name, email, or organization membership from SQL, join against a mirror table, and keep that table updated with webhooks.

Clerk sends webhooks through Svix and signs every payload, so the first rule is non-negotiable: verify the signature before you trust anything in the body.

import { verifyWebhook } from '@clerk/nextjs/webhooks'

export async function POST(req: Request) {
  const event = await verifyWebhook(req) // validates svix-id, svix-timestamp, svix-signature

  switch (event.type) {
    case 'user.created':
    case 'user.updated':
      await upsertMirrorUser(event.data)
      break
    case 'organization.created':
      await upsertMirrorTenant(event.data)
      break
    case 'organizationMembership.created':
    case 'organizationMembership.deleted':
      await syncMembership(event.data)
      break
  }

  return new Response('ok', { status: 200 })
}

At minimum, mirror three tables: public.users, public.tenants, and public.tenant_memberships. That covers the four events above and gives you fast relational joins without a network call back to Clerk on every request. Svix also retries failed deliveries on a schedule, so use the svix-id header as an idempotency key and make your handlers safe to run twice for the same event.

This mirror is also what makes the rollback plan in the next section possible. Without it, reverting to Supabase Auth means reconstructing tenant membership from scratch.

Zero-Downtime Cutover: Dual-Auth Bridge and Rollback Plan

Run both identity providers side by side until you've proven the new one works under real traffic, then remove the bridge. Treating an auth migration as an instant, one-way switch is how teams end up debugging a production incident with no way back.

A practical cutover runs through five stages:

  1. Dual-auth bridge. Deploy transitional RLS policies that accept a request authenticated by either the legacy Supabase session or the new Clerk session, falling back to a membership lookup when the Clerk-specific claims aren't present:
    -- Accepts either a Clerk-issued token or a legacy Supabase session during cutover
    create policy "tenant_read_dual_auth"
    on public.invoices
    for select
    to authenticated
    using (
      tenant_id = coalesce(
        (select auth.jwt()->>'org_id'),            -- present on Clerk tokens
        (select tenant_id from public.memberships   -- fallback for legacy Supabase sessions
         where user_id = auth.uid())
      )
    );
  2. Adversarial SQL testing. Before touching real traffic, simulate both token types in a test transaction and confirm tenant isolation holds for both:
    -- Simulate a Clerk-authenticated request inside a rolled-back transaction
    begin;
    select set_config('request.jwt.claims', '{"sub":"user_abc123","org_id":"org_xyz","role":"authenticated"}', true);
    select * from public.invoices; -- should return only org_xyz's rows
    rollback;
  3. Client traffic cutover. Route frontend authentication to Clerk. The dual-auth policies mean existing sessions keep working while new sessions come through Clerk.
  4. Rollback safety. Keep the historical Supabase UUID in Clerk's externalId field and in your mirror tables. If something breaks after cutover, you can route traffic back to Supabase Auth without touching the schema, because the mapping never went away.
  5. Legacy cleanup. Once the new flow has run cleanly through a full billing cycle, drop the bridge policies and disable the old Supabase Auth endpoints. Leaving both running indefinitely just doubles your attack surface for no benefit.

The teams who get burned skip straight to stage three. The bridge and the testing stage are what make stages four and five optional instead of mandatory.


Five-stage timeline showing a zero-downtime dual-auth cutover from Supabase Auth to Clerk.

Quick Answers About Clerk Supabase RLS Migration

What Causes auth.uid() to Return NULL With Clerk?

Simply put, auth.uid() tries to cast the JWT's sub claim to a UUID, and Clerk's user IDs are text strings like user_2abC9xYz, not UUIDs. The cast fails or returns NULL depending on context. Switch affected policies to (select auth.jwt()->>'sub') instead, and this stops happening immediately.

Clerk vs Supabase Auth at a Glance

AspectSupabase Auth (native)Clerk (third-party)
User ID formatUUID (auth.users.id)Text string (user_xxxxxxxx)
RLS identity helperauth.uid()(select auth.jwt()->>'sub')
Multi-tenancy modelCustom schema you design yourselfBuilt-in Organizations with org_id / org_role claims
Token signingHS256 (legacy) or project-issued RS256RS256 via Clerk-hosted JWKS
Step-up / MFA in RLSNot exposed as a JWT claim by defaultfva claim readable directly inside policies
Legacy integration statusN/AJWT template method deprecated April 1, 2025

When Does This Migration Apply to You?

This applies if you're already running a live multi-tenant Supabase application with real users and foreign keys pointing at auth.users. If you're starting a brand-new project, just wire up Clerk's native integration directly. The concerns in this guide (password preservation, UUID mapping, dual-auth cutover) only exist because you have production data that can't experience downtime.

Pros and Cons of Migrating to Clerk

  • Pro: Native Organizations, roles, and permissions instead of building tenant primitives yourself
  • Pro: Prebuilt sign-in, user profile, and org-switcher UI components cut frontend auth work substantially
  • Pro: Step-up verification (the fva claim) is readable directly inside an RLS policy
  • Con: Every UUID foreign key referencing auth.users needs a migration plan, shadow mapping or a direct refactor
  • Con: An added vendor dependency and per-MAU billing on top of your existing Supabase costs
  • Con: Edge Functions need manual JWT verification, since the platform gateway doesn't resolve third-party JWKS yet

Frequently Asked Questions

How do I connect Clerk to Supabase for RLS?

Activate the Supabase integration from Clerk's dashboard, copy your Clerk domain, then add it as a provider under Authentication > Sign In / Providers in Supabase. Once active, Clerk's session tokens carry a role: authenticated claim, and RLS policies read the rest through auth.jwt().

Can I use Row Level Security with Clerk and Supabase together?

Yes. Supabase's native Third-Party Auth integration is built for exactly this. RLS still runs inside Postgres the same way it always did, you just read identity and organization data from auth.jwt() instead of auth.uid(), since Clerk issues the token now.

How do I migrate existing users from Supabase Auth to Clerk without resetting passwords?

Export id, email, and encrypted_password from auth.users, then call Clerk's createUser() for each row with passwordDigest and passwordHasher set to match your original algorithm. Existing users sign in with their current password; Clerk verifies it against the imported hash.

What's the difference between Supabase Auth and Clerk for multi-tenant apps?

Supabase Auth gives you a users table and JWTs, and you build organizations, roles, and invites yourself. Clerk ships Organizations, roles, and permissions as built-in primitives, exposed as org_id and org_role claims you read directly inside RLS policies.

How do Clerk Organizations map to tenants in Postgres RLS policies?

Treat each Clerk Organization's ID as your tenant_id. Session tokens include org_id and org_role (or the nested o.id / o.rol claims), so a policy filters with tenant_id = (select auth.jwt()->>'org_id') instead of joining through a memberships table.

Is the Clerk-Supabase JWT template integration deprecated?

Yes, as of April 1, 2025. Supabase now recommends the native Third-Party Auth integration using RS256 and Clerk's JWKS endpoint instead. The old JWT template method still works unofficially, but it required sharing your Supabase JWT secret with Clerk, which Supabase no longer recommends.

Does Clerk work with Supabase Edge Functions?

Not automatically. Edge Functions verify JWTs at a platform gateway that doesn't resolve third-party JWKS endpoints yet. Deploy the function with --no-verify-jwt and verify the Clerk token manually inside the handler against your Clerk domain's JWKS endpoint instead.

Can I roll back to Supabase Auth if the Clerk migration fails?

Yes, if you plan for it upfront. Keep the historical Supabase UUID in Clerk's externalId field and in a mirror table, and leave the dual-auth bridge policies active until you're confident in the cutover. That lets you route traffic back without touching your schema.

Conclusion

Every top-ranking page for "Clerk Supabase RLS" assumes you're starting from an empty database. Production migrations aren't that simple, and pretending otherwise is how teams end up debugging silent RLS failures in front of a customer instead of in a staging environment.

The actual work concentrates in five places: preserving password hashes through Clerk's Backend API, resolving the UUID-versus-string identifier mismatch in your schema, hoisting your RLS policies so they scale past a few hundred rows, handling token verification correctly across SSR and Edge Functions, and running a dual-auth cutover you can actually reverse if something goes wrong.

Get the RLS policies right first. That's also where custom JWT claims replace the joins you were probably running before, covered in more depth in our guide to writing RLS policies with custom JWT claims. Everything else in this migration builds on getting that layer right.

Bookmark this before you start the cutover. You'll want the rollback checklist on hand, not half-remembered, once you're routing real traffic through it.