> ## Documentation Index
> Fetch the complete documentation index at: https://wiredesk.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Security model

> How the widget key, allowed domains, verified visitors, iframe isolation, rate limits and API call actions are enforced, for security reviewers.

This page is for whoever reviews WireDesk before it goes on your site. It covers what is public and what is secret, where each control is enforced, and what a visitor, the host page and the agent can each reach.

## The widget key is public

A widget key (`wk_` followed by 32 hex characters) sits in your page source. It identifies one widget and lets the holder chat with one agent. It does not open the dashboard, conversations or any other data. Treat it like a publishable key, not a secret.

Three controls limit what someone can do with a copied key:

* **Allowed domains** stop it working on other sites.
* **Rate limits** stop it being scripted.
* **Your workspace spend cap** puts a hard limit on what any abuse costs. See [spend cap](/docs/account/spend-cap).

## Allowed domains

A widget starts with no list, and a widget without a list works on any site. Once you save a list on **Agents → your agent → Install → Allowed domains**, it is enforced in three places. You can list up to 20 domains, and each covers its subdomains: `example.com` matches `example.com`, `www.example.com` and `shop.example.com`. `localhost` is accepted for local development.

| Where                                                | What happens to a site that is not listed                                                                                                          |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Config endpoint** (`GET /api/widget/<key>/config`) | Answers `403` with `{"error": "this widget is not allowed on this domain", "domain": "<origin>"}`.                                                 |
| **Conversation frame** (`/w/<key>`)                  | Served with `Content-Security-Policy: frame-ancestors` naming only your domains, so the browser refuses to render the frame inside any other page. |
| **Chat endpoints** (send, poll, list, end)           | Answer `403` to a request whose `Origin` is neither WireDesk's own nor on your list.                                                               |

More detail on each:

* **CORS on the config endpoint.** With no list, the response carries `Access-Control-Allow-Origin: *` and `Cache-Control: public, max-age=60`. Once a list exists, it echoes the caller's own origin, adds `Vary: Origin` and switches to `Cache-Control: private, max-age=60`, so a shared cache cannot hand one site's answer to another. The config fetch is made with credentials omitted.
* **Frame ancestors.** For each listed domain the policy allows `https://domain`, `https://*.domain`, `http://domain:*` and `http://*.domain:*`, so a developer's local http build still works. No `X-Frame-Options` is sent on this path, because that header cannot express a list.
* **Chat requests without an `Origin`.** The panel's own polling requests are same-origin and carry no `Origin` header. They are accepted only when the browser marks them `Sec-Fetch-Site: same-origin`, or, on browsers that do not send that header, when the `Referer` is WireDesk's own origin. A caller with neither header is refused.

The config endpoint lets a request with no `Origin` header through. Only browsers can report which website is embedding the widget, and a browser sends `Origin` on this cross-origin request.

See [Allowed domains](/docs/widget/allowed-domains) for setup.

## Verified visitors

Verified visitors are off by default. When you choose **Generate a signing secret**, the widget gets a per-widget secret: `whs_` followed by 64 hex characters, from 32 random bytes. The dashboard shows it masked. **Reveal**, **Rotate** and **Turn off** need the admin or owner role, and each change is recorded in the activity log.

* **Signature:** the HMAC-SHA256 of the user id, hex-encoded, keyed with that secret. Your server computes it. The secret never goes into the page.
* **Check:** the server recomputes the digest and compares the two in constant time. A digest that is not 64 lowercase hex characters, or that does not match, makes the identity **invalid**. The identity is then ignored, and nothing in it is trusted.
* **Identity sent to a widget without a secret:** treated as invalid and ignored.
* **Ownership:** a verified conversation belongs to `email:<address>` when you send an email, or otherwise to the id you signed. Every chat endpoint checks the conversation's owner against the verified identity or the visitor id. Reading or ending someone else's conversation gets `404`. Sending to it starts a new conversation instead.
* **Require verification:** with this on, a message without a valid identity gets `403`. An invalid signature is answered with "we couldn't verify who you are — please reload the page". A missing one gets "this chat is only available when you're signed in".
* **Rotate** takes effect immediately. Deploy the new secret to your servers first.

### Where the identity travels

1. Your page gives `widget.js` the identity, through `window.wiredeskSettings.user` or `wiredesk("identify", …)`.
2. `widget.js` passes it to the panel in the iframe URL's **fragment** (`#wd=…`), or by `postMessage` if the panel is already open. It never goes in a query string. Browsers do not send fragments to servers, so the identity stays out of access logs and `Referer` headers.
3. The panel sends it to the chat endpoints in an **`X-Wd-Identity`** request header, as percent-encoded JSON.

The server caps the fields: `id` at 128 characters, `name` at 120, and `email` at 254. An invalid email is dropped.

## Iframe isolation

`widget.js` draws a launcher button on your page and nothing else. The conversation runs in an iframe served from `https://wiredesk.ai/w/<key>`:

* **Same-origin chat traffic.** Every chat request is made by the iframe to its own origin. Your page does not call the chat API, and the browser's same-origin policy stops your page's scripts, and anyone else's, from reading the transcript inside the frame.
* **Checked `postMessage` both ways.** `widget.js` only acts on messages from the WireDesk origin that come from its own frame. It posts to the frame with the WireDesk origin as the target. The panel only acts on messages from its parent window.
* **What your page learns.** It receives only what the [events](/docs/developers/events) carry: open and close, the text and role of messages the panel receives, and an unread count.
* **Late loading.** The iframe is not created until a visitor hovers, focuses, touches or clicks the launcher, or your code opens it.

## Rate limits and caps

Every public chat request counts against two limits at once. The send, poll, recent-chats and end requests all count.

| Limit key                          | Limit                      |
| ---------------------------------- | -------------------------- |
| widget key + visitor id            | 30 requests per 60 seconds |
| widget key + connecting IP address | 30 requests per 60 seconds |

The IP limit is the one a script cannot get around by inventing a new visitor id for each request. Over either limit, the endpoint answers `429` with `Retry-After: 60` and "You're sending messages too quickly. Give it a moment."

If the limiter itself is unavailable, requests are allowed rather than failing chat for everyone, and the spend cap is the backstop.

A single visitor message is capped at 2,000 characters.

## API call actions

An [API call](/docs/actions/api-calls) action makes the agent send an HTTP request to a URL you configure. Because a model fills in the parameters from what a member of the public typed or said, the request is constrained on both sides.

**When you save the action:**

* The URL must be `https`.
* The host must be a hostname, not an IPv4 or IPv6 address. `localhost`, hosts ending in `.localhost`, `.local`, `.internal`, `.home.arpa` or `.onion`, and single-label hosts are refused.
* Every `{{placeholder}}` in the URL, headers or body must be a declared parameter. Parameter keys are short snake\_case words, with at most 10 per action.
* `{{credential}}` can go in a header or the body, never in the URL.

**Each time it runs:**

* **Only declared parameters are filled in.** The model cannot add others.
* **Values are URL-encoded** in the URL.
* **The URL is checked again** with the same rules after the values are filled in, so a placeholder in the host cannot reach a private address.
* **Redirects are not followed.** A `3xx` response comes back as an error.
* **Timeouts:** 5 seconds on a phone call, 15 seconds on chat and email.
* **The response is cut off** at 1,500 characters on a call and 4,000 on chat and email.

The checks apply to the URL, not to the address its hostname resolves to. A hostname whose DNS points at a private address is not caught. Point actions only at hosts you control.

**Credentials:**

* Encrypted at rest with AES-256-GCM.
* Only an admin or owner can add one.
* After saving, they are only ever shown as their label and last four characters.
* A credential is attached to the outgoing request, as a bearer token, a named header or a `{{credential}}` placeholder. It is never put in the model's context. The agent sees only the response status and body.

## Response headers

Every response from the app carries:

| Header                      | Value                                 |
| --------------------------- | ------------------------------------- |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
| `X-Content-Type-Options`    | `nosniff`                             |
| `Referrer-Policy`           | `strict-origin-when-cross-origin`     |

Every page except the conversation frame also carries `X-Frame-Options: DENY` and `Content-Security-Policy: frame-ancestors 'none'`, so the dashboard cannot be framed or clickjacked. The conversation frame at `/w/<key>` carries the `frame-ancestors` list described above, and no framing restriction if the widget has no list.

Plain `http` requests are redirected to `https` with a `308`, which keeps the method and body, and `www.wiredesk.ai` redirects to `wiredesk.ai`.

The app does not send a full script-src Content-Security-Policy.

`widget.js` is served with `Cache-Control: public, max-age=300, stale-while-revalidate=3600`, so a fix reaches embedded sites within minutes.

## Related

* [Allowed domains](/docs/widget/allowed-domains)
* [Verified visitors](/docs/widget/verified-visitors)
* [API calls](/docs/actions/api-calls)
* [Account security](/docs/account/security)
