Authentication
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.
X-User-Id is not an auth boundary
The widget sends no user id — the server never asks the browser who it is. Even if a
host application adds a header like X-User-Id itself, the handler ignores it for
authorization: the browser controls request headers, and any header can be forged.
Identity must come from your verified server session — a cookie or JWT that your
auth provider signed and that only your server can read. See the
security model.
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;
}Warning
Never derive the user id from req.headers.get('X-User-Id'), query params, or the
request body — those are client-controlled values. Doing so re-introduces the IDOR
this design exists to prevent.
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.
Note
If you need the raw org id inside a tool or system-prompt hook, read it from
ctx.request using your auth library — it is available on ChatRequestContext.request.
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.