Build Flows

Custom applications · October 9, 2026 · 9 min read

How to Safely Embed an AI Chat Widget in a Client Portal

The design behind Connect's embeddable assistant: a dependency-free loader, an isolated iframe, admin-minted embed tokens that are scoped, short-lived, hashed and revocable, and the checklist for embedding any AI assistant safely.

By Charley Forey, founder of Build Flows

Sooner or later, someone asks whether the AI assistant can live inside the client portal. Or the intranet, or the owner's project site, or a dashboard the operations team already uses. The people who would get the most from the assistant aren't always people you want to give a full login to the platform behind it.

Connect, the AI platform we built on top of Syncify, has an answer: one script tag that adds a floating chat button to any page, backed by a short-lived token scoped to one workspace and optionally one project. This article covers how that widget is built and, more usefully, the security reasoning behind each piece. If you're putting an AI assistant into a portal of your own, these are the decisions you'll face.

What the host page actually does

The host page includes a single script with a few data attributes: the base URL of the Connect deployment, an embed token and, optionally, a project id. The loader has no dependencies and no external CSS. It:

  1. Injects a small style block and a fixed bottom-right button.
  2. On first click, creates an iframe pointing at /embed/connect-ai on the Connect origin, with the token (and project, if set) in the query string.
  3. Toggles the iframe's visibility on later clicks.
  4. Listens for postMessage events from the iframe, but only from the Connect origin, so it can respond to open and close requests.
  5. Exposes a tiny open / close / toggle API on window so the host can drive it from its own buttons.

That's all it does. The loader never stores the token, never calls the API, and never touches the host page's DOM beyond its own button and frame.

The iframe matters. The chat UI runs on the Connect origin, so its API calls are same-origin with the server. The host page can't read the conversation or inject script into the chat. The browser's same-origin policy does most of the isolation work.

The token is the whole credential

Embedded viewers don't have Connect accounts, and you don't want them to. The widget authenticates with an embed token instead, and that token is everything the embedded session is allowed to be.

PropertyHow it's enforced
Minted by a workspace adminThe mint route requires workspace admin. Granting outside access to a workspace's data is an admin action.
Scoped to one workspaceThe workspace comes from the token row, never from the URL or request body
Optionally pinned to one projectA nullable project_id on the token, checked to exist in that workspace at mint time
Short-livedLifetime given in minutes, capped at 24 hours in both the request schema and the module
RevocableA revoked_at timestamp. Revocation takes effect on the next request.
Stored hashedOnly a SHA-256 hash is stored. The raw token is returned exactly once, at mint time.
LabelledA short label ("Owner portal, Tower B") so admins can tell tokens apart

The raw token is 32 random bytes, base64url-encoded, the same scheme as user sessions. Because only the hash is stored, a database leak doesn't yield a working token. The admin view of a token never includes the raw value or its hash. The hash column isn't even part of the read query's shape.

Validation fails closed and gives nothing away. An unknown token, a revoked token, an expired token, and a token whose minting admin has since been deleted all produce the same 401: "invalid or expired embed token." An attacker probing tokens can't tell "close" from "wrong."

Bearer auth, not cookies

Connect's normal app uses a SameSite session cookie with a same-origin check against CSRF. That model fails inside a cross-site iframe: browsers won't send a SameSite cookie when the frame's parent is a different site, which is the whole point of SameSite.

So the embed routes use a different credential. The embed page reads the token from its URL once, keeps it in memory, and sends it as Authorization: Bearer <token> on every request. There are two consequences:

  • It works cross-site. A header doesn't depend on cookie rules.
  • It needs no CSRF check. CSRF works by getting the browser to attach a credential automatically. A bearer token that an attacker's page can't read can't be attached by that page.

The page also removes the token from the address bar as soon as it loads, using history.replaceState. A token left in the URL ends up in browser history, bookmarks and, depending on referrer policy, in requests to other origins. Removing it costs one line.

Scope comes from the token, never from the request

The embed chat API has three routes: create a conversation, read a conversation, and post a message that streams the answer back. Every one of them gets the workspace, the project scope and the acting user from the validated token. None of them accepts a workspace or project from the path or body. A host page, or someone with dev tools open, has nothing to edit that would widen access.

Inside the chat engine, embedded threads get extra limits:

  • Each conversation records which token created it. Reading or posting to a conversation requires the same token that created it, so one embed token can't read another token's threads. A mismatch returns "not found", not "forbidden".
  • No widening mid-thread. The logged-in chat has a scope switcher that can move a turn from one project to another, or to every project the user can see. Embedded threads skip it completely.
  • No all-workspaces scope, at two layers. The chat module refuses to create an embedded conversation with the cross-workspace scope. Under that, a database check constraint makes the combination of the "all" scope and a non-null embed token impossible to store. If the application check were ever removed by mistake, the insert would still fail.

The same ChatModule runs logged-in and embedded chats. Only authentication differs. This was deliberate: a second, simplified chat engine for embeds would drift away from the main one, and safety fixes would land in one and not the other. Embedded turns go through the same guardrails, tool gating and tracing as every other turn. The layered safety guardrails and anatomy of a chat turn articles cover what that pipeline does.

Whose authority does the widget use?

An embedded turn has to run as someone, because tool permissions, project visibility and audit trails all need a principal. We chose the admin who minted the token, limited further by the token's workspace and project scope. That keeps one permission model instead of inventing a separate "anonymous viewer" role with its own rules to maintain.

The practical guidance follows from that choice: mint from an account whose access matches the audience. Pin tokens to one project where you can, and if the embed audience should see less than a project's full data, mint from a dedicated, deliberately limited account rather than relying on the model to hold back. A token whose minting account has been deleted stops validating, so the principal can never quietly become nobody.

Who may frame it: frame-ancestors

Every page Connect serves sends X-Frame-Options: DENY, except pages under /embed. X-Frame-Options can't express an allowlist, so embed pages send a Content Security Policy frame-ancestors directive instead, listing the host origins the deployment allows. If nothing is configured, it defaults to the deployment's own origin, so a fresh install can't be framed by anyone else.

This stops someone else's site from framing your assistant inside their page. It's one layer, not the whole story: frame-ancestors governs where the page can be framed, and the token's own limits (short lifetime, narrow scope, revocation) do the rest. Treat embed tokens as bearer credentials and design those limits first.

Routes are declared, and tested

Connect keeps a single route manifest that lists every HTTP route with its required authorization level, and a test asserts that the routes actually registered match the manifest exactly. embed is its own level in that manifest, separate from the session tiers:

  • Mint, list and revoke tokens: workspaceAdmin, cookie-authenticated.
  • Create, read and post to embedded conversations: embed, bearer-authenticated.

The embed routes are registered inside their own Fastify scope, with the bearer hook attached to that scope only. Fastify's encapsulation is the boundary: an embed token reaches the three embed chat routes and nothing else. It can't list files, read schedules or call admin endpoints, because none of those routes are in a scope that accepts it. Reading the embed context from a route registered outside that scope throws immediately, so a wiring mistake shows up as a loud error instead of an open door. The route manifest and authorization chokepoint article goes into the pattern.

A safe rollout checklist for embedding an AI assistant

We expect most readers will embed some assistant somewhere, whether or not it's Connect. Here is what we'd check:

  1. Decide the principal first. Know exactly whose permissions an embedded turn uses, and make that principal as narrow as the audience.
  2. Scope tokens to the smallest unit that works. One project beats a whole workspace. Never allow cross-tenant scope from an embed.
  3. Keep tokens short-lived and plan for rotation. A 24-hour ceiling means a token pasted into a page goes stale within a day. That's fine for a demo or a scheduled review session. A permanent portal integration needs a server-side step that mints fresh tokens, and you should design that step before launch, not after the first expiry.
  4. Treat embed tokens as bearer credentials. Keep them short-lived, narrowly scoped and revocable, mint them from a limited account, and place the snippet on pages that are already behind your own login.
  5. Allowlist framing origins explicitly. Default to deny.
  6. Store token hashes, show the raw token once, and support revocation that takes effect on the next request.
  7. Attribute every embedded thread to its token. That gives you an audit trail, a revocation story ("which conversations used this token?") and a basis for per-token limits.
  8. Reuse the main chat pipeline. Same guardrails, same tool gating, same tracing. A separate "lite" pipeline is where safety regressions hide.

Why this matters if you're building something similar

The pattern generalizes to any assistant you put in front of people who don't have platform accounts, such as an owner portal, a subcontractor intranet or a field kiosk. The lessons:

  • An iframe on your own origin is the isolation layer. Let the browser keep the host page and the assistant apart.
  • Get authority from the credential, never from the request. If a route accepts a workspace id from the URL, someone will eventually edit it.
  • Enforce the most dangerous restriction twice. For us that was "no cross-workspace scope from an embed", in code and as a database check constraint.
  • Use one engine and many front doors. Authentication is the only thing that should differ between logged-in and embedded chat.
  • Layer the controls. A frame allowlist decides who can host the widget. Token lifetime, scope and revocation bound what any token can do.

If your portal is the bigger project, our guide to building a custom client portal for construction covers what goes around the assistant: status, schedule, pay apps, documents and approvals.

Where to go next

Want an assistant inside your own portal? Talk to us about an engagement.

Frequently asked questions

How do you embed an AI assistant in a client portal without giving users accounts?

Frame the assistant from your own origin and authenticate the frame with a short-lived token minted by an admin and scoped to a workspace or single project. Derive all access from the token on the server and keep the token revocable and hashed at rest.

Why use a bearer token instead of a cookie for an embedded chat widget?

Browsers do not send SameSite session cookies to a cross-site iframe. A bearer token in the Authorization header works cross-site and needs no CSRF protection, because a page that cannot read the token cannot attach it.

Does frame-ancestors make an embed token safe to share?

It is one layer. frame-ancestors controls which sites can frame the page. Treat the token as a bearer credential: keep it short-lived, narrowly scoped and revocable, mint it from a limited account, and place the snippet behind your own login.

Whose permissions does an embedded AI chat use?

Embedded turns run under the account that minted the token, narrowed to the token's workspace and optional project. That keeps one permission model; mint from an account whose access matches the audience, and pin tokens to a project where you can.

How long should an embed token last?

Connect caps embed tokens at 24 hours. Short lifetimes limit the damage from a leaked token, but they mean a permanent portal integration needs a server-side step that mints fresh tokens regularly.

Next step

Need something built around how your team works?

Describe the users, the workflow, and the systems it touches. We'll tell you whether a custom application makes sense and how we'd build it.

Prefer email? charley@buildflows.ai

Get the next guide in your inbox

Field Notes: practical guides and new walkthroughs, about once a month.

Field Notes

Practical guides and new walkthroughs on construction data and automation, roughly monthly.

Keep learning