Security model

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:

code
// ❌ 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:

code
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 or StorageAdapter, 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 page rather than a public issue.