Authenticated users were unable to preview their chats in QCX because the Clerk authentication system and the QCX-BACKEND Supabase backend were not properly synchronized. The root cause was that the clerk_user_id column on the public.users table was never being populated, which caused all Clerk-aware Row Level Security (RLS) policies to fail — specifically, the is_clerk_user() function always returned false, blocking all authenticated users from accessing any data.
The synchronization failure stemmed from three conflicting user creation paths that each created user records differently without establishing the Clerk-to-Supabase ID bridge:
When a user signed up via Clerk, Clerk's JWT was passed to Supabase. The handle_new_user trigger fired on auth.users insert and created a public.users record with the Supabase-generated UUID as id, but it never set clerk_user_id.
The webhook received Clerk events and used Drizzle ORM to upsert into the QCX app's own database (a separate connection via DATABASE_URL). It created records with clerk_user_id set, but these records lived in the app's database, not in QCX-BACKEND.
The edge function received the same Clerk webhooks and performed a direct table upsert into QCX-BACKEND. However, it was using the Clerk ID (e.g., user_abc123) as the value for the id column — which is a uuid type — and it never set clerk_user_id.
All three paths operated in isolation. None of them linked the Supabase UUID to the Clerk ID in the clerk_user_id column. Since all RLS policies depend on is_clerk_user(user_id) which checks clerk_user_id = clerk_id(), and clerk_user_id was always NULL, every RLS policy denied access.
-
sync_clerk_user()RPC function: A canonical function that handles all three cases — existing user by Clerk ID, existing user by email (linking), and new user creation. Returns the Supabase UUID for the record. -
Updated
handle_new_usertrigger: Now extracts the Clerk user ID fromauth.jwt()metadata and stores it inclerk_user_id. -
Updated
clerk_id()function (v1, later reverted): Initially attempted to extract the Clerk user ID from the JWTsubclaim:SELECT auth.jwt() ->> 'sub'. However, this returned the Supabaseauth.users.id(UUID), not the Clerk user ID string, causing all RLS policies to fail. -
Updated
is_clerk_user(user_id)function: Properly checks that the user'sclerk_user_idmatches the current session's Clerk ID. -
Updated all RLS policies on 8 tables:
users,chats,messages,calendar_notes,chat_participants,locations,system_prompts,visualizations. All policies now correctly useis_clerk_user()alongsideauth.uid(). -
users_updated_attrigger: Ensures theupdated_attimestamp is properly maintained on user records. -
Deployed edge function v13: The
database-accessedge function now callssync_clerk_user()RPC instead of performing direct table upserts.
-
Updated webhook route (
app/api/clerk/webhook/route.ts): Now syncs users to both the app's database (via Drizzle) and QCX-BACKEND (via Supabase RPC). This ensures both systems have consistent user records with the correctclerk_user_id. -
Updated schema (
lib/db/schema.ts): Addedfirst_name,last_name,avatar_url,created_at,updated_at,username,phone_number, andmetadatacolumns to match the QCX-BACKEND users table. -
Updated
resolveClerkUserToDbUser(lib/auth/get-current-user.ts): Now syncs full profile data from Clerk (name, avatar) when resolving users, and properly links existing email-based records to their Clerk ID. -
New Drizzle migration (
drizzle/migrations/0003_clerk_auth_sync.sql): Persists all the QCX-BACKEND SQL changes (functions, triggers, RLS policies) into the repo's tracked migration system.
All 6 critical components verified as active in QCX-BACKEND:
sync_clerk_userfunction: activeclerk_idfunction: activeis_clerk_userfunction: activeclerk_user_idcolumn onusers: presenton_auth_user_createdtrigger: activeusers_updated_attrigger: active- Edge function
database-accessv13: deployed and ACTIVE - RLS policies: 12 policies across 8 tables, all using
is_clerk_user()
When a user authenticates with Clerk:
- Clerk issues a JWT containing the Clerk user ID as the
subclaim. - The Next.js app uses Clerk middleware to validate the JWT and extract the Clerk ID.
resolveClerkUserToDbUser()resolves the Clerk ID to the Supabase UUID, creating/linking the record if needed.- The webhook route simultaneously syncs the user to QCX-BACKEND via
sync_clerk_user(). - When querying chats, RLS policies check
is_clerk_user(user_id)which now correctly returnstruebecauseclerk_user_idis properly set. - The user can now see their chat history.
The initial clerk_id() function from Fix v1 returned the wrong value. It used SELECT auth.jwt() ->> 'sub' which returns the Supabase auth.users.id (a UUID like 85a812de-...), but the clerk_user_id column stores Clerk user IDs (strings like user_3GEXlPN...). Since these two ID formats will never match, the is_clerk_user() function and all RLS policies that depend on it always returned false, blocking all Clerk-authenticated users from accessing data.
In Supabase, auth.jwt() ->> 'sub' returns the Supabase internal user ID (UUID from auth.users.id). It does NOT return the Clerk user ID. Clerk and Supabase have separate identity systems:
- Supabase: Uses UUIDs (e.g.,
85a812de-f4e4-4cba-aee7-24efe4d663e3) - Clerk: Uses prefixed strings (e.g.,
user_3GEXlPNzzZIDVjF2kCadMx5NvNf)
The public.users table bridges these by storing both: id (UUID, same as auth.users.id) and clerk_user_id (Clerk string).
-
Rewrote
clerk_id()function: Instead of returningauth.jwt() ->> 'sub', it now looks up the Clerk ID from thepublic.userstable using the current session'sauth.uid():SELECT public.users.clerk_user_id FROM public.users WHERE public.users.id = auth.uid() LIMIT 1;
This correctly returns the Clerk user ID string (e.g.,
user_3GEXlPNzzZIDVjF2kCadMx5NvNf) for the currently authenticated user. -
Verified
is_clerk_user()function: No changes needed — it already correctly callspublic.clerk_id()and compares againstclerk_user_id. With the fixedclerk_id()function, this now works correctly. -
Re-applied all 12 RLS policies across 8 tables to ensure they use the corrected
clerk_id()andis_clerk_user()functions.
All components verified after fix:
clerk_id()function: returns Clerk user ID string (not Supabase UUID)is_clerk_user()function: correctly returnstruefor Clerk-authenticated users- RLS policies: 12 policies across 8 tables, all using corrected
is_clerk_user() - Correct SQL query pattern:
SELECT id FROM users WHERE clerk_user_id = '<clerk-id>'works
When a Clerk-authenticated user makes a request:
- Clerk authenticates the user and provides a JWT.
- Supabase receives the request with a valid session.
auth.uid()returns the Supabase UUID (e.g.,85a812de-...). clerk_id()looks uppublic.users.clerk_user_idwherepublic.users.id = auth.uid(), returning the Clerk ID (e.g.,user_3GEX...).- RLS policies check
clerk_user_id = clerk_id()— now comparinguser_3GEX...=user_3GEX...→true. - The user can access their chats, messages, and all other data.
- Migration
0004_fix_clerk_id_function.sqlapplied to QCX-BACKEND (project:mofqapxwyphzjrqegjeq). - All 12 RLS policies re-applied with corrected function references.
- The migration is tracked in the repo's Drizzle migration system.
- Run via
npm run db:migrateorbun run db:migrateto apply to local/dev environments.
Under the Clerk native Third-Party Auth integration with Supabase, there is no need for a shared JWT secret. Supabase validates tokens issued directly by Clerk using Clerk's JSON Web Key Set (JWKS) URL.
- Go to your Clerk Dashboard.
- Navigate to JWT Templates (or Integrations -> Supabase if using Clerk's legacy integration, but for Native Third-Party Auth, navigate to Configure -> API Keys -> JWT Templates if you wish to configure scopes/claims, or simply use Clerk's native JWT signature).
- Under Clerk's Native Third-Party Auth, Supabase directly retrieves the JSON Web Key Set (JWKS) from Clerk to verify signature of any standard token issued by Clerk. Therefore, standard session tokens from Clerk can be sent directly to Supabase.
- Go to your Supabase Project Dashboard.
- Navigate to Authentication -> Third-Party Auth.
- Enable Clerk Third-Party Auth integration.
- Set the following fields:
- Clerk JWKS URL: Enter your Clerk JWKS URL (e.g.,
https://clerk.<your-domain>.com/.well-known/jwks.jsonor development URL likehttps://<your-dev-domain>.clerk.accounts.dev/.well-known/jwks.json).
- Clerk JWKS URL: Enter your Clerk JWKS URL (e.g.,
- Save the settings.
The application uses Supabase JS clients initialized with a custom fetch handler. The fetch handler intercepts out-going requests to Supabase and appends the dynamic Clerk session JWT in the Authorization header as Bearer <clerkToken>.
- Client-Side (Browser): Retrieves token via Clerk's browser SDK:
window.Clerk.session.getToken(). - Server-Side (Next.js server/API/Actions): Retrieves token via
@clerk/nextjs/serverauth()helper:auth().getToken().
Both clients use persistSession: false since Clerk manages the session lifecycle independently. Tokens are sent without the { template: 'supabase' } option because Supabase validates Clerk tokens directly against the Clerk JWKS.
In a multi-user, real-time collaboration environment, dual-write consistency issues can arise between the primary application database (Drizzle) and the auth/edge function sync in the Supabase backend (QCX-BACKEND). To prevent desynchronization from breaking RLS-authorized Realtime and Storage access, we implement several safety measures:
- Stamping Ownership on Individual Messages: In
saveChat, instead of stamping all messages with the chat owner's ID, each message is stamped with theuserIdof the individual user that sent it (userId: msg.userId || effectiveUserId). This prevents concurrent collaborators' messages from being rewritten or misattributed. - Deterministic Deduplication: Messages inside a batch transaction are deduplicated using their unique
idand upserted viaonConflictDoUpdate(ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, role = EXCLUDED.role). This ensures concurrent writes from different collaborators do not conflict or cause duplicate messages. - Graceful User Existence Check: Webhook path (/api/clerk/webhook) syncs new users to BOTH the primary Drizzle database and the Supabase backend. Furthermore,
getCurrentUserIdOnServer()usesresolveClerkUserToDbUserwhich deterministically creates or links profile-aligned user records on-demand whenever an action is executed. This guarantees a user is always represented in both stores before attempting database operations. - Resilient Realtime Subscriptions: Supabase Realtime subscriptions follow the exact same RLS policies deployed in QCX-BACKEND. By ensuring that
is_clerk_user()and thechat_participantsaccess checks are fully reactive, collaborators instantly receive changes they have permission to see, and are automatically unsubscribed/unauthorized on the fly if their access role changes.