Tutorials
Tutorial: Connect Clerk Auth to Supabase in Your CONTENTFORGER App
The five-minute setup that makes Clerk users match Supabase rows and row-level security work correctly.
By Øyvind — 2026-04-06, last updated 2026-04-06
CONTENTFORGER outputs apps with Clerk for auth and Supabase for database. Connecting them properly takes five minutes and saves hours of debugging later.
The problem
Clerk manages users. Supabase stores data. Without the right plumbing, your app has no way to know that Clerk user X owns Supabase row Y.
The fix is small: sync the Clerk user ID into a users table in Supabase, and use that ID in your queries.
Step one: the users table
Open Supabase SQL Editor. Run:
create table users ( id uuid default gen_random_uuid() primary key, clerk_id text unique not null, email text, plan text default 'free', created_at timestamptz default now() );
The clerk_id column is what ties everything together.
Step two: sync on first login
In your CONTENTFORGER app, open or create app/api/webhooks/clerk/route.ts. Clerk sends webhook events when users sign up.
Add a handler that catches user.created events:
import { Webhook } from 'svix' import { supabase } from '@/lib/supabase'
export async function POST(req: Request) { const payload = await req.text() const headers = Object.fromEntries(req.headers) const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!) const evt = wh.verify(payload, headers) as any
if (evt.type === 'user.created') { await supabase.from('users').insert({ clerk_id: evt.data.id, email: evt.data.email_addresses[0]?.email_address }) }
return new Response('ok') }
Step three: register the webhook with Clerk
In the Clerk dashboard, go to Webhooks. Add a new endpoint: - URL: https://your-domain.com/api/webhooks/clerk (or use ngrok for local) - Events: user.created, user.updated, user.deleted - Copy the signing secret into CLERK_WEBHOOK_SECRET in your env.
Step four: query Supabase with the Clerk user ID
In any authenticated route, get the Clerk user ID and filter Supabase by it:
import { auth } from '@clerk/nextjs/server' import { supabase } from '@/lib/supabase'
export default async function DashboardPage() { const { userId } = await auth() if (!userId) return <div>Not authed</div>
const { data: user } = await supabase .from('users') .select('*') .eq('clerk_id', userId) .single()
const { data: projects } = await supabase .from('projects') .select('*') .eq('user_id', user.id)
return <ProjectList projects={projects} /> }
Step five: row-level security
RLS is how you make sure users can only see their own data. In Supabase SQL Editor:
alter table projects enable row level security;
create policy "Users see own projects" on projects for select using (user_id = ( select id from users where clerk_id = auth.jwt()->>'sub' ));
For this to work, you need Clerk to pass the JWT to Supabase. CONTENTFORGER output handles this via a Clerk template JWT.
Testing
Sign up a new user in your app. Check Supabase users table. A new row should appear with the Clerk ID.
Insert a test project row. Log in as that user. Query the projects. Only your rows should return.
Log in as a different user. Query the projects. Should return empty.
If both users see both sets of rows, RLS is not active or the policy is wrong. Re-run the policy creation and verify.
What you just avoided
Without this flow, you end up storing Clerk IDs in every table, writing ad-hoc auth checks in every query, and eventually shipping an authorization bug because you forgot a check somewhere.
With this flow, RLS enforces the rules at the database level. Even if your application code has a bug, the database refuses to return other users' data.
This is worth the five minutes.