ADR-0074: Publishable keys mint anonymous, conversation-bound tokens for browsers
| Status | Accepted |
| Date | 2026-10-15 |
| Issue | #195 (and #194, test tokens) |
| Pull request | #197 |
| Related | ADR-0059, ADR-0065, ADR-0013 |
Context and problem statement
A client token comes from POST /api/v1/converger/tokens/generate, authenticated with the channel secret. That
works when the site embedding a chat widget has a backend to hold the secret. Many sites do not: static sites, CMS
pages, landing pages. Their only option today is to put the channel secret in the page, and then anyone who reads
the page source can:
- mint tokens for any
user.id, so impersonate the site's real users (their conversations, their identity in agent consoles); - use the channel's per-channel
token_generatebucket (10 per minute), so lock every visitor out; - revoke other users' tokens with
tokens/revoke, which also accepts the secret.
A second, smaller need came up while trying chativa against a local hub: an operator who wants to try a channel has to keep the secret (shown once) and run curl to get a token (#194).
Decision drivers
- A credential in page source must not let its reader act as anyone other than a new anonymous visitor.
- One abusive client must not be able to lock a channel for everyone.
- A visitor who reloads the page continues the same conversation.
- No new infrastructure, and nothing that weakens the secret-based flow.
- Self-service: a tenant can embed a widget on its own site without an operator changing
CORS_ORIGINS.
Considered options
- Secret in the browser - document the risk and leave it to tenants.
- Publishable key that mints unscoped tokens - like
tokens/generate, without a caller-chosen user id. - Publishable key that mints conversation-bound tokens, with a signed resume credential - the endpoint creates the conversation and the anonymous user; continuing needs a credential only the server can sign.
- Publishable key with a client-chosen visitor id - the client keeps a random id and sends it back.
Pros and cons of the options
Option 1
- Good, because there is nothing to build.
- Bad, because every risk above stays, and it is the path of least resistance for every static site.
Option 2
- Good, because the widget works like today's flow (create a conversation, then use it).
- Bad, because an unscoped token can open any conversation of its channel by id
(
GET /conversations/:idreturns a token bound to it, seeConvergerWeb.Helpers.Authorization). Conversation ids are random UUIDs, but they appear in links, logs and agent tools; a public key must not turn knowing an id into reading the conversation.
Option 3
- Good, because a minted token can only ever reach the conversation created for it; no existing authorization rule changes.
- Good, because the resume credential is unforgeable (
Phoenix.Token, signed withsecret_key_base) and names the channel, the conversation and the user, so it cannot be replayed against another channel or pointed elsewhere. - Bad, because every call without
resumecreates a conversation, so the per-IP and per-channel limits matter.
Option 4
- Good, because it needs no signing.
- Bad, because a visitor id is a bearer credential the client invents: anyone who learns it (or guesses a weak one) becomes that visitor, and nothing stops a client from sending an id that a backend uses for a real user.
Decision
Chosen option: "Publishable key that mints conversation-bound tokens, with a signed resume credential" (3), because it is the only option where the key's reader can do nothing but be a new anonymous visitor.
- The key. A channel may have one publishable key (
cvg_pk_+ 32 random bytes, base64url), stored in plain text (it is public) with a partial unique index, and a list of allowed origins (exactscheme://host[:port]or a subdomain wildcardhttps://*.example.com, at most 20). A key needs at least one origin. Enabling, rotating (the old key stops at once) and disabling are audited, as is changing the origins. - The endpoint.
POST /api/v1/converger/tokens/publishablewithAuthorization: Bearer cvg_pk_.... Checks, in order: the per-IP limitpublishable_token_ip(10/min, before any database work), the key (active channel), theOriginheader against the allowed origins (missing or not allowed:403 origin_not_allowed), the per-channel limitpublishable_token(300/min, tenant-overridable). - What it mints. An anonymous user
anon_+ 16 random bytes, a new conversation (metadata.source: "publishable_key") and a token bound to it with the claimsrc: "publishable". The response carriesresume. - Resume. A
Phoenix.Tokenover{channel, conversation, user}, valid 7 days (PUBLISHABLE_RESUME_MAX_AGE). A valid credential for an open conversation of the same channel returns a fresh token for it (resumed: true); anything else starts a new conversation (resumed: false) rather than failing, so a stale browser recovers. - Refresh.
tokens/refreshkeepssrc, and a token withsrc: "publishable"refreshes against thepublishable_tokenbucket instead of the secret flow's 10/mintoken_generate, which a busy widget would exhaust. - CORS. On
/api/v1/converger/*only, the endpoint's CORS origin function also allows the request's origin when an active publishable key lists it (Converger.Channels.PublishableOrigins, cached per node for 30 s and invalidated on change). The native WebSocket has no origin check (tokens, not cookies), so the widget needs nothing else. Admin, portal and tenant API paths never gain origins this way. - Test tokens (#194). The channel page's Client access panel (admin roles
super_admin/admin, portal rolesowner/admin) issues an unscoped token for an operator-chosen user id, exactly whattokens/generatereturns, audited astest_tokenwith the token'sjti. The same panel manages the publishable key.
Consequences
Positive
- Sites without a backend can embed a widget safely; the secret never needs to leave a server.
- A leaked or abused key is contained: it cannot impersonate, cannot read other conversations, cannot revoke, and is limited per IP; rotating it stops it at once.
- Operators try a channel from the panel without handling the secret.
Negative and trade-offs
- The
Origincheck stops other websites from using the key in their visitors' browsers; it is not authentication. A script outside a browser can send anyOrigin. The limits, not the origin check, bound what such a script can do (create anonymous conversations). - Every call without
resumecreates a conversation; abandoned ones accumulate like any other and are handled by retention. - Rotating
SECRET_KEY_BASEinvalidates every resume credential: visitors start new conversations. - An origin added to one channel's key is allowed by CORS on the whole client API (CORS preflights carry no credential, so the channel is unknown at that point). The client API authenticates with bearer tokens, never cookies, so this exposes nothing a page could not already reach with a token.
- Up to 30 s after a change, other nodes may still allow a removed origin in CORS (the token endpoint itself checks the database every time).
Follow-ups
- chativa:
PublishableKeyAuthin@chativa/connector-convergerand the sandbox's credential modes (AimTune/chativa#91). - Identified visitors on top of publishable keys (a site's own login signing the user id) are out of scope.
Implementation
- Migrations
20261015180000_add_publishable_key_to_channels(columns) and20261015180100_add_publishable_key_index_to_channels(concurrent partial unique index). Converger.Channels.Origins(normalizing and matching),Converger.Channels.Channel.client_access_changeset/3,Converger.Channels.update_client_access/4,get_channel_by_publishable_key/1,issue_test_token/3.Converger.Auth.PublishableResume, thesrcclaim inConverger.Auth.ConvergerToken.ConvergerWeb.ConvergerAPI.PublishableTokenController, the refresh bucket choice inConvergerWeb.ConvergerAPI.TokenController,ConvergerWeb.Endpoint.cors_origins/1,Converger.Channels.PublishableOrigins.ConvergerWeb.Live.ChannelAccessandConvergerWeb.ChannelComponents.client_access_panel/1.- Tests:
test/converger/channels/origins_test.exs,test/converger_web/controllers/publishable_token_controller_test.exs,test/converger_web/live/channel_access_live_test.exs.