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.

code
app/api/chat/[[...chat]]/route.ts
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:

code
lib/chat-auth.ts (scaffolded)
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

code
lib/chat-auth.ts
import { auth } from '@clerk/nextjs/server';
 
export async function getChatUserId() {
  const { userId } = await auth(); // verified session cookie — never a header
  return userId; // string | 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:

code
lib/chat-auth.ts
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.

code
lib/chat-auth.ts
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:

  1. Constructs a ChatStore bound to that id — no method on the store accepts a foreign user id.
  2. Constructs a StorageAdapter bound to that id — paths are namespaced and unguessable.
  3. Calls ensureConversation before any write — a forged conversation id is rejected with 403 before 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.