Authentication
How to implement getChatUserId against your verified server session, why X-User-Id is not a trust boundary, and multi-tenant patterns.
The single security boundary you own is getChatUserId (or getUserId in createChatHandler). Everything else — conversation ownership, per-user scoping, signed attachment URLs — is enforced by the package once it has a verified identity from you. This page covers how to implement it correctly.
How getChatUserId is called
createChatHandler passes the raw Request to your getUserId function as the only argument. You read cookies or call your auth library’s server-side session API. Return the verified user id as a string, or null for an unauthenticated request.
import { createChatHandler } from '@mordn/chat-widget/server';
import { getChatUserId } from '@/lib/chat-auth';
export const { GET, POST, DELETE } = createChatHandler({
getUserId: getChatUserId,
// …
});
null → the handler responds 401 before any conversation data is touched.
The throwing stub
The scaffolded lib/chat-auth.ts throws on every call until you implement it:
export async function getChatUserId() {
throw new Error('getChatUserId is not implemented. See lib/chat-auth.ts.');
}
This is deliberate. A fresh install fails closed — every request returns 500 — so you cannot accidentally ship an unauthenticated widget. The stub forces you to make an active, intentional decision about identity before any user data is persisted.
Auth provider examples
import { auth } from '@clerk/nextjs/server';
export async function getChatUserId() {
const { userId } = await auth(); // verified session cookie — never a header
return userId; // string | null
}import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export async function getChatUserId() {
const session = await getServerSession(authOptions);
return session?.user?.id ?? null;
}import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function getChatUserId() {
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll: () => cookieStore.getAll() } },
);
const { data } = await supabase.auth.getUser();
return data.user?.id ?? null;
}Accessing the request inside getUserId
getUserId receives the full Request object. You can read verified cookies directly when your auth library requires it:
export async function getChatUserId(request: Request) {
// Some libraries want you to pass the request explicitly
const session = await getSessionFromRequest(request);
return session?.userId ?? null;
}
The request is there so you can read cookies the browser sent — not so you can read a header the browser also sent and claimed was the user id.
Multi-tenant and per-org ids
For multi-tenant applications you may want a composite identity — e.g. {orgId}:{userId} — so conversations are scoped to an organisation and never bleed across tenants.
import { auth } from '@clerk/nextjs/server';
export async function getChatUserId(request: Request) {
const { userId, orgId } = await auth();
if (!userId) return null;
// Scope every conversation to the org.
// All three verified values come from the server session — not from headers.
return orgId ? `${orgId}:${userId}` : userId;
}
The ChatStore and StorageAdapter are then constructed bound to that composite id, and no query or path ever crosses the org boundary.
What happens after authentication
Once getUserId returns a non-null string, the handler:
- Constructs a
ChatStorebound to that id — no method on the store accepts a foreign user id. - Constructs a
StorageAdapterbound to that id — paths are namespaced and unguessable. - Calls
ensureConversationbefore any write — a forged conversation id is rejected with403before any data lands.
Cross-user access is not just checked — it is unrepresentable at the type level. See the security model for the full argument, and createChatHandler for the complete option reference.