Thread Metadata Security#
Chainlit persists session-scoped backend state β including chat_profile, chat_settings, client_type, and optionally env (user-provided environment variables such as API keys) β into thread metadata on every disconnect. The to_persistable() method in session.py assembles this blob, and persist_user_session() in socket.py writes it to the data layer.
The core security issue: most thread-reading endpoints return this raw metadata verbatim to the client, with no redaction of the fields that were only intended to drive server-side logic.
What Gets Stored in Thread Metadata#
| Field | Source | Sensitive? |
|---|---|---|
chat_profile | Selected chat profile name | Low β but opaque to third parties |
chat_settings | User-configured widget values | Medium β may contain prompts, toggles |
client_type | webapp / copilot / etc. | Low |
env | User-supplied API keys & secrets | High β conditionally stripped (see below) |
| Arbitrary keys | App-set via cl.user_session.set() | Varies β developer-controlled |
env is controlled by config.project.persist_user_env. When that flag is False (the default), to_persistable() replaces env with an empty dict before writing to the data layer . Even so, a persist_user_env = true deployment will write raw API keys into metadata.
The thread metadata system is documented in the tags & metadata guide.
Endpoints and Their Redaction Posture#
GET /project/thread/{thread_id} β No redaction#
Returns the raw data-layer thread, including all metadata, to the authenticated thread owner . There is no stripping of chat_profile, chat_settings, or env.
WebSocket resume_thread β No redaction#
resume_thread() in socket.py fetches the full thread and copies the metadata dict directly into user_sessions . The full thread object (including metadata) is then forwarded to the on_chat_resume callback and emitted back to the client via context.emitter.resume_thread(thread) .
GET /project/share/{thread_id} β Explicit redaction β#
The shared-thread endpoint introduced an explicit server-side redaction step: it pops chat_profile, chat_settings, and env from metadata before returning the response . This is the only thread-serving endpoint that applies this treatment.
Inconsistency Summary#
The thread-sharing feature (added later) deliberately strips sensitive metadata fields before sending a thread to viewers . The original GET /project/thread/{thread_id} endpoint and the WebSocket resume flow do not. This creates an asymmetry: authenticated thread owners receive full backend metadata on GET/resume, while unauthenticated (or different-user) shared-thread viewers get a cleaned copy.
Whether exposing chat_profile and chat_settings to the thread's own authenticated owner is a real risk depends on the deployment. The danger escalates when:
persist_user_env = trueis set and the data layer is exposed to the client.user_sessionsvalues contain secrets set viacl.user_session.set().- The custom data layer's
get_threadresponse is cached or logged.
Key Source Files#
| File | Relevance |
|---|---|
backend/chainlit/session.py | to_persistable() β assembles metadata written to the DB |
backend/chainlit/socket.py | persist_user_session(), resume_thread() β writes and reads metadata |
backend/chainlit/server.py | get_thread() (no redaction), get_shared_thread() (with redaction) |
| Docs: thread-sharing | Feature overview, on_shared_thread_view callback, security considerations |
| Docs: tags & metadata | Thread metadata model |
Mitigation Paths#
- Apply the same redaction used in
get_shared_threadtoget_threadand the WebSocket resume emitter if owner-facing exposure is a concern. - Never store secrets via
cl.user_session.set()β that dict is included verbatim into_persistable(). - Keep
persist_user_env = false(the default) to prevent API keys from reaching the data layer. - Implement
on_shared_thread_viewto perform additional application-level redaction before data leaves the server .