---
title: "Security model"
description: "How @mordn/chat-widget keeps one user’s conversations and attachments private from another’s — and the one thing you must do to uphold it."
---

This page explains how `@mordn/chat-widget` keeps one user's conversations and
attachments private from another's, and the one thing **you** must do to uphold
it.

## TL;DR

- **You establish identity on the server.** Implement `getChatUserId(req)` to
  return the user id from your *verified* server session.
- **No client-supplied id is an auth boundary.** The widget sends none, and a
  header like `X-User-Id` added by the host is ignored for authorization.
  Never trust one.
- **The package enforces the rest.** Conversation ownership, per-user scoping,
  private attachments, and signed URLs are handled inside the package — not your
  responsibility to wire up correctly.

## The threat: IDOR

IDOR — *Insecure Direct Object Reference* — is when a server uses a
client-supplied identifier to fetch data **without checking the requester owns
it**. For a chat product that identifier is the user id (and the conversation
id). A route like this:

```ts
// ❌ NEVER do this
const userId = req.headers.get('X-User-Id'); // the browser controls this
return getConversations(userId);             // returns anyone's chats
```

…lets any user read or write any other user's conversations by changing a
header. It's the single most important class of bug for a multi-user chat app,
and it's easy to introduce by accident.

## How the package prevents it

### 1. Identity comes from your server session

`createChatHandler` calls **your** `getUserId(request)` and uses whatever it
returns as the only identity for the request. Implement it against your auth
system's *server-verified* session:

```ts title="lib/chat-auth.ts"
import { auth } from '@clerk/nextjs/server';

export async function getChatUserId() {
  const { userId } = await auth(); // from a verified session cookie/JWT
  return userId;
}
```

The `request` is passed in so you can read **verified** cookies — not so you can
read a client-asserted id. Returning a value from `req.headers`, query params,
or the JSON body re-introduces the IDOR. Return `null` for an unauthenticated
request and the handler responds `401`.

> **The stub throws until you implement it**
>
> A freshly scaffolded `getChatUserId` throws on every request. That's
> deliberate — a fresh install fails closed, so it's never silently insecure.

### 2. The data layer is bound to one user

Internally, the store and storage adapters are **constructed bound to the
verified `userId`** (`ChatStoreFactory = (userId) => ChatStore`). None of their
methods accept a user id. There is no parameter through which a foreign id could
enter, so "fetch user B's data while acting as user A" *cannot be expressed in
the code* — it's a type-level guarantee, not a convention you must remember.

- `getConversation(id)` returns `null` when the conversation exists but belongs
  to another user — indistinguishable from "not found", so existence can't be
  probed.
- Creating or writing a conversation owned by another user is rejected with a
  `ConversationOwnershipError` (HTTP `403`) before anything is persisted.
- Listing and message reads are implicitly scoped to the bound user.

### 3. Attachments are private by default

The default storage adapter:

- writes to a **private** bucket (never a public URL),
- returns **short-lived signed URLs**, re-signed on demand when old
  conversations are reloaded,
- stores files under **user-namespaced, unguessable paths** derived from the
  bound user id, and refuses to sign or delete anything outside that namespace.

> **Warning**
>
> The attachments bucket you create **must be private**. The adapter never relies
> on public read; making the bucket public reopens a hole the package otherwise
> closes.

## Your responsibilities

> **Before you ship**
>
> - Implement `getChatUserId` to return the id from your **server** session.
> - Never read identity from `X-User-Id`, query params, or the request body.
> - Create the attachments bucket as **private** (if you keep uploads).
> - Keep `SUPABASE_SERVICE_ROLE_KEY` server-side only — never `NEXT_PUBLIC_`.

If you bring your own [`ChatStore`](/docs/api/chat-store) or
[`StorageAdapter`](/docs/api/storage-adapter), uphold the invariants documented
on those interfaces — they are the security boundary for the custom path.

## Reporting a vulnerability

Please open a private report at the
[GitHub security advisories](https://github.com/arnavv-guptaa/chat-widget/security/advisories)
page rather than a public issue.
