---
title: "Authentication"
description: "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.

> **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](/docs/security).

## 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.

```ts title="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:

```ts title="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

**Clerk**

```ts title="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
}
```

**NextAuth**

```ts title="lib/chat-auth.ts"
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';

export async function getChatUserId() {
  const session = await getServerSession(authOptions);
  return session?.user?.id ?? null;
}
```

**Supabase**

```ts title="lib/chat-auth.ts"
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:

```ts title="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.

```ts title="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](/docs/security) for the full argument, and [`createChatHandler`](/docs/api/create-chat-handler) for the complete option reference.
