Hosting, CSP and security
This page is for the people who host the embedding page and the people who review it: how the frame is allowed on your page, what your own Content-Security-Policy must permit, why the chat works where third-party cookies are blocked, what to configure behind a reverse proxy, and a checklist for the security review.
Enterprise module
Embedding is part of the enterprise portals module — see Embedding a portal.
What the console checks, in order
Every token exchange runs the same pipeline. The order is the security design: everything before step 4 answers the same generic refusal whatever failed, so the endpoint tells a stranger nothing about what exists.
| Step | Check | Refusal |
|---|---|---|
| 1 | The address is well formed, and the caller's address has not used up its allowance of refused attempts. | token_invalid / rate_limited |
| 2 | The workspace and portal exist, the workspace is licensed, the portal is enabled and allows embedding, and the portal's allowance of verified tokens is not used up. | token_invalid / rate_limited |
| 3 | The site that declares itself the host is exactly one of the Allowed sites. (The only other way through is the administrator's Try it page, with a signature only the admin API can mint, valid ten minutes.) | token_invalid |
| 4 | The token: size, algorithm, signature, iss, aud, sub, iat, exp, lifetime, claims. From here on the caller has proved it holds your signing key, so answers become specific. | token_invalid, token_expired |
| 5 | The token is taken: single use, decided by the database — so it holds on every node and when the cache is off. | token_replayed |
| 6 | The person: matched or created by the identity rules, checked against owners and administrators, groups brought up to date. | identity_not_found, identity_privileged, account_disabled, quota_exceeded |
| 7 | The account is active, and the person is not creating sessions at a rate no person does. | account_disabled, rate_limited |
| 8 | The session is created: a random bearer token, only its hash stored, bound to the portal, the person and the site. | — |
A failure to check is a refusal, never a pass: if the database cannot be reached the exchange answers exchange_unavailable (HTTP 503).
Framing: who may put the portal in a frame
The frame is /api-embed/portal/<key>?tenant=<slug>&origin=<host origin>. It is the only page of the console that can be framed, and it answers with:
| Header | Value |
|---|---|
Content-Security-Policy | frame-ancestors <your allowed sites>; base-uri 'none'; form-action 'none'; object-src 'none'; img-src 'self' data: blob: |
X-Frame-Options | absent on this page — every other page of the console carries DENY |
X-Content-Type-Options | nosniff |
Referrer-Policy | same-origin |
Cache-Control | no-store |
frame-ancestors is built from the Allowed sites list, each entry re-read as an exact origin (scheme, host, port) so a stored value can never add a directive or a keyword to the header. No site you did not list can frame the chat — and the chat itself refuses to talk to a page that is not the origin it was told it is embedded in, or one the browser reports as a different embedding page (ancestorOrigins).
The path starts with api on purpose: the console's session middleware, which would send a visitor without a cookie to the login page and stamp X-Frame-Options: DENY on the answer, leaves such paths alone.
One answer for every "no". A wrong workspace or portal address, a portal that is switched off or does not embed, an unlicensed workspace, a site that is not on the list, and the address opened as a page of its own (the browser says the request is not for a frame) all get the same answer: frame-ancestors 'none' and a page that says Embedding is turned off. The real list of sites goes only to a request that declares one of them, and the portal's look and your page tools reach the frame only after a person has signed in.

Your page's Content-Security-Policy
If your page sets a CSP, allow what the widget needs:
| Directive | Allow | Why |
|---|---|---|
script-src | https://console.example.com | The widget script, /api/embed/portal.v1.js. |
frame-src (or child-src) | https://console.example.com | The chat frame. |
connect-src | 'self' (your token endpoint) and https://console.example.com | getToken calls your server. The widget also reads the bubble's look from the console (/api/portal/<key>/embed/widget); if blocked, the defaults are used, or pass remoteLook: false. |
style-src | 'unsafe-inline', or a policy that permits one injected <style> | The script adds a single <style id="cpp-style"> element for the bubble. Under a strict style-src the bubble would be unstyled. Pages that cannot allow it can use the plain iframe instead, which injects nothing. |
Replace console.example.com with your console's address. The widget sandboxes the frame — allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals, no top navigation — and delegates only the clipboard (allow="clipboard-write"), with referrerpolicy="strict-origin-when-cross-origin".
Your page cannot read inside the frame: it is a different origin. What your page learns is only the events the chat posts — ids and sizes, never message text unless you ask for eventContent: 'text'.
Third-party cookies, Safari and why it is a bearer token
Browsers increasingly block or partition cookies for a page inside a frame of another site; Safari does by default. A design that needed the console's session cookie inside your frame would break there. This one never uses a cookie:
- the exchange is a
POSTwithcredentials: "omit"— the host's signed token is the credential; - the session token lives in the frame's memory and is sent as
Authorization: Bearer cp_es_…; - nothing about the sign-in is stored in
localStorage,sessionStorageor a cookie, so there is nothing for Intelligent Tracking Prevention to clear; - the console's own cookie is never read for an embedded request, even when the person is also signed in to the dashboard in the same browser (they are the host's person, not the cookie's owner).
The cost: a page reload is a new sign-in — one more call to your token endpoint and one more exchange. That is cheap by design.
Behind a reverse proxy or CDN
- Route
/api-embed/*,/api/embed/*and/api/portal/*to the console. The chat streams answers over server-sent events, so do not buffer responses to/api/portal/*. - Do not cache
/api-embed/*or/api/portal/*. (The widget script,/api/embed/*.js, is meant to be cached; itsETagmakes revalidation cheap.) - Do not add your own
X-Frame-Optionsorframe-ancestorsto/api-embed/*— the console sets the right ones per portal, and a proxy header would break framing. - Forward
Host/X-Forwarded-Hostunchanged, and list the proxy inTRUSTED_PROXIES(Configuration). Without it every person looks like the proxy, so the pace limits, a network restriction on the portal, and a session tied to the network range all see the proxy's address. - State-changing requests must come from the console's own origin. The frame's requests do. A script on your page calling
/api/portal/*directly is refused as a cross-site request (CROSS_SITE_REQUEST, HTTP 403) — talk to the console only through the frame. - A page opened from
file://has the originnull, which can never be an allowed site. Serve development pages fromhttp://localhost, which works only on development and self-hosted installations.
Pace and abuse limits
The exchange is public, so what is limited — and by what — matters:
| Limit | Value | Counted by |
|---|---|---|
| Refused attempts | 20 a minute | The caller's network address — the connection's own peer unless trusted proxies are configured, never a header the caller wrote. Only refusals count, so a person who signs in properly is never slowed. |
| Verified tokens | 600 a minute | The portal. Only tokens that passed the signature check count, so a flood of forged requests, from however many addresses, cannot lock your people out. |
| New sessions | 20 a minute | The person. |
| Refusal audit rows | 120 a minute for strangers, 600 for requests that carried a valid signature | The workspace. A flood is refused just as quickly but cannot fill the log. |
Counters are kept per node and bounded.
Security checklist
- [ ] The signing secret or private key lives only on your server (a secret store or environment) — never in a page, a mobile app bundle, a repository or a log.
- [ ] The token endpoint authenticates the request with your own session and takes the person only from that session, never from a parameter.
- [ ] Tokens are minted per call, live 120–300 seconds, carry a fresh
jti, and the response isCache-Control: no-store. - [ ] Allowed sites lists only pages you control — exact origins, https, no more than needed.
- [ ] Issuer and audience are copied from the Embed tab; with one key for several workspaces, Require the workspace in the audience is on.
- [ ] Match people by and When nobody matches are the narrowest that work. Trust the host's addresses is off unless your application verifies every address it signs.
- [ ]
email_verified: trueis sent only for addresses your application verified. - [ ] People who arrive through the embed is Must be on the access list unless your application truly decides who may see the chat.
- [ ] Session length is as short as is comfortable (30 minutes is plenty), and your sign-out handler calls
logout(). - [ ] Your CSP allows the console's script and frame — and nothing broader.
- [ ]
TRUSTED_PROXIESis set behind a reverse proxy. - [ ] Secrets are rotated on a schedule and after any suspicion — remove, regenerate, and Sign everyone out.
- [ ] Page-tool handlers treat their arguments as untrusted, and changing tools are marked Ask the person first.
- [ ] The audit log for
portal.embed.refusedis monitored — a burst oftoken_invalidis someone probing; a burst ofidentity_not_foundis a mapping problem.
What the design defends against
| Threat | Defence |
|---|---|
| A forged token | Signature checked against only the configured key; algorithm pinned (no none, no algorithm switching, no key confusion); iss, aud, exp, iat and lifetime enforced. |
| A replayed token | Single use by jti, decided by the database; a refused attempt never uses a token up. |
| A token made for another portal or workspace | The audience names the portal, and with the option on, the workspace. |
| A malicious page framing the chat | frame-ancestors per portal; the chat refuses to talk unless its declared origin is allowed and is the embedding page; messages are accepted only from the parent window and the declared origin, and sent only to that origin — never *. |
| A stolen session token | In memory only; expires; optionally tied to the browser or network; revocable; not renewable without the host's token. |
| Taking over an administrator | Owners and administrators are never signed in by a token. |
| A token granting more than intended | Groups from a token only fill groups created for that issuer, and never ones that carry roles or permissions. |
| The model, or the page, lying | ctx and page context are never used for access; page tool results reach the model marked untrusted. |
| Enumerating workspaces or portals | One generic refusal for everything that fails before the signature is checked. |
| Flooding the exchange or the audit log | Pace limits on refusals, per portal and per person; capped refusal auditing. |
What embedding cannot protect against
A script that runs in your page — through an XSS flaw, a malicious dependency or a compromised tag manager — can call your token endpoint as the signed-in person and drive their chat. The portal cannot tell that apart from the person. Protect the page the way you protect any page that holds a signed-in session.

