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-Idadded 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:
// ❌ 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:
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)returnsnullwhen 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(HTTP403) 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
getChatUserIdto 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_KEYserver-side only — neverNEXT_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.