Skip to content

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.

StepCheckRefusal
1The address is well formed, and the caller's address has not used up its allowance of refused attempts.token_invalid / rate_limited
2The 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
3The 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
4The 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
5The token is taken: single use, decided by the database — so it holds on every node and when the cache is off.token_replayed
6The 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
7The account is active, and the person is not creating sessions at a rate no person does.account_disabled, rate_limited
8The 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:

HeaderValue
Content-Security-Policyframe-ancestors <your allowed sites>; base-uri 'none'; form-action 'none'; object-src 'none'; img-src 'self' data: blob:
X-Frame-Optionsabsent on this page — every other page of the console carries DENY
X-Content-Type-Optionsnosniff
Referrer-Policysame-origin
Cache-Controlno-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.

The frame opened outside a frame

Your page's Content-Security-Policy ​

If your page sets a CSP, allow what the widget needs:

DirectiveAllowWhy
script-srchttps://console.example.comThe widget script, /api/embed/portal.v1.js.
frame-src (or child-src)https://console.example.comThe chat frame.
connect-src'self' (your token endpoint) and https://console.example.comgetToken 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 POST with credentials: "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, sessionStorage or 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; its ETag makes revalidation cheap.)
  • Do not add your own X-Frame-Options or frame-ancestors to /api-embed/* — the console sets the right ones per portal, and a proxy header would break framing.
  • Forward Host / X-Forwarded-Host unchanged, and list the proxy in TRUSTED_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 origin null, which can never be an allowed site. Serve development pages from http://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:

LimitValueCounted by
Refused attempts20 a minuteThe 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 tokens600 a minuteThe 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 sessions20 a minuteThe person.
Refusal audit rows120 a minute for strangers, 600 for requests that carried a valid signatureThe 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 is Cache-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: true is 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_PROXIES is 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.refused is monitored — a burst of token_invalid is someone probing; a burst of identity_not_found is a mapping problem.

What the design defends against ​

ThreatDefence
A forged tokenSignature 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 tokenSingle use by jti, decided by the database; a refused attempt never uses a token up.
A token made for another portal or workspaceThe audience names the portal, and with the option on, the workspace.
A malicious page framing the chatframe-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 tokenIn memory only; expires; optionally tied to the browser or network; revocable; not renewable without the host's token.
Taking over an administratorOwners and administrators are never signed in by a token.
A token granting more than intendedGroups from a token only fill groups created for that issuer, and never ones that carry roles or permissions.
The model, or the page, lyingctx and page context are never used for access; page tool results reach the model marked untrusted.
Enumerating workspaces or portalsOne generic refusal for everything that fails before the signature is checked.
Flooding the exchange or the audit logPace 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.

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub