ContentForger apps sign users in with Supabase Auth and keep their data in Supabase Postgres. Auth and data live in the same Supabase project, so there is no second user system to keep in sync. This tutorial shows how the pieces fit together and how to prove that one user can never read another user's rows.
Earlier versions of ContentForger generated apps with a separate auth provider. If your project's package.json lists an auth package other than @supabase/ssr, it is one of those. Everything below describes what ContentForger generates today.
What the generator is told to do
When an app stores data that belongs to users, ContentForger's instructions to the model require:
- Authenticate with Supabase Auth through @supabase/ssr.
- Resolve the current user id on the server for every read and write of user-owned data.
- Give user-owned tables a user_id column with Row Level Security policies tied to the auth uid.
- Repeat the ownership check in the server-side data layer. Never rely on the client.
- Return 401 when signed out and 403 when signed in but not the owner.
Generated code varies from run to run, so file names in your project can differ from the ones below. The pattern is the same.
Step one: keys and redirect URLs
In the Supabase dashboard, click Connect and copy the project URL and the publishable key (older projects call it the anon key). Put them in .env.local under the names your .env.example lists, for example:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-keyThere are no separate auth keys. The same project serves auth and data.
Then open Authentication, URL Configuration. Set the Site URL to http://localhost:3000 while you develop, and add your local and live addresses to Redirect URLs. Sign-in links and OAuth redirects only go to addresses on that list.
Step two: the server client
@supabase/ssr keeps the session in cookies, so server code can read it. The server client usually lives in lib/supabase/server.ts:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
} catch {
// Server Components cannot write cookies; the proxy refreshes the session instead.
}
},
},
}
)
}Create a new client for every request. Never keep one in a module-level variable, or one user's session can leak into another user's request.
Step three: refresh the session in the proxy
Server Components cannot write cookies, so an expired access token has to be refreshed before the page renders. That is the job of proxy.ts at the project root. Next.js 16 renamed middleware.ts to proxy.ts; an app that still has middleware.ts works the same way.
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/proxy'
export async function proxy(request: NextRequest) {
return await updateSession(request)
}updateSession builds a Supabase client from the request cookies, calls supabase.auth.getClaims() to refresh the token, and copies the new cookies onto the response. Take it from Supabase's server-side client guide rather than writing it by hand: the order of the calls matters, and code placed between creating the client and getClaims() causes random sign-outs.
Step four: read the user on the server
In any page, Server Action or Route Handler that touches user data, ask Supabase who is signed in. Use getClaims() or getUser(), never getSession(): getSession() reads the cookie without validating it.
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export default async function DashboardPage() {
const supabase = await createClient()
const { data } = await supabase.auth.getClaims()
const userId = data?.claims?.sub
if (!userId) redirect('/sign-in')
const { data: projects } = await supabase
.from('projects')
.select('id, name, created_at')
.eq('user_id', userId)
return <ProjectList projects={projects ?? []} />
}The .eq('user_id', userId) filter is the server-side check. RLS in the next step is the second lock. getClaims() validates the token's signature; when you need to know the account was not signed out or deleted a moment ago, call getUser(), which asks the Auth server.
Step five: row-level security
Signed-in users have the Postgres role authenticated, and auth.uid() returns their id inside the database. Tie every user-owned table to it. In the Supabase SQL Editor:
create table projects (
id uuid primary key default gen_random_uuid(),
user_id uuid not null default auth.uid() references auth.users (id) on delete cascade,
name text not null,
created_at timestamptz not null default now()
);
alter table projects enable row level security;revoke all on projects from anon; grant select, insert on projects to authenticated;
create policy "Users read own projects"
on projects for select to authenticated
using ((select auth.uid()) = user_id);
create policy "Users create own projects"
on projects for insert to authenticated
with check ((select auth.uid()) = user_id);Grants decide whether signed-in users may touch the table at all; policies decide which rows. Not every Supabase project grants new tables automatically, and on projects that do, signed-out visitors get access too, so set both explicitly. A permission denied error (code 42501) means a missing grant, not a wrong policy. If users can edit or remove rows, add the update and delete grants together with matching policies.
ContentForger puts tables and policies like these in supabase/schema.sql, so in practice you run that file in the SQL Editor instead of typing them.
There is no mirror users table and no sign-up webhook. Accounts live in auth.users, which Supabase manages. If you need profile fields such as a display name, add a profiles table keyed on the same id.
Choosing sign-in methods
Which sign-in screens your app gets depends on your prompt, so name the methods you want. Supabase Auth supports, among others:
- Email and password: supabase.auth.signUp and supabase.auth.signInWithPassword.
- Magic links: supabase.auth.signInWithOtp, with emailRedirectTo pointing at your callback route.
- OAuth providers such as Google or GitHub: supabase.auth.signInWithOAuth.
Switch each method on under Authentication, Sign In / Providers in Supabase. OAuth providers also need a client ID and secret from the provider. Magic links and OAuth come back to a callback route such as app/auth/callback/route.ts, which calls supabase.auth.exchangeCodeForSession(code) and then redirects into the app. @supabase/ssr uses the PKCE flow, so a magic link has to be opened in the same browser that asked for it.
One trap before launch: Supabase's built-in email sender only delivers to members of your Supabase team, has a low hourly limit, and is not meant for production. Set up custom SMTP in the Authentication settings (Resend is one of the supported services), or real users never receive their confirmation and magic-link emails.
Testing
Sign up two users in your app, ideally in two different browsers. Both should appear in Supabase under Authentication, Users.
As user A, create a project. As user B, load the dashboard. B must see none of A's projects.
Then test the database lock on its own. Temporarily remove the .eq('user_id', userId) filter and reload as B. B should still see only B's rows, because RLS filters them. If B now sees A's projects, RLS is off or a policy is wrong. Put the filter back afterwards. Do not test this from the SQL Editor: it runs as an admin role that bypasses RLS and shows every row.
Finally, call one of your API routes without a session. It should answer 401.
What you just avoided
With a separate auth provider you keep two user systems in step: a webhook that copies every new account into your database, a mirror table, and a signed token handed across so the database can trust it. Each of those is a place where a sign-up can go missing.
With Supabase Auth, the user id your code checks and the id the database checks are the same auth.uid(). RLS enforces ownership at the database level, so even if a route forgets its check, the database refuses to return other users' rows.
Run the two-user test once for every table that holds user data. It takes five minutes.




