> For the complete documentation index, see [llms.txt](https://shoppad.gitbook.io/yedric/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://shoppad.gitbook.io/yedric/developers/secure-mode.md).

# Secure Mode

Secure mode ties chat sessions to a verified user in your app. Without it, the widget uses an anonymous browser-local `sessionId`. With it, the widget sends a verifiable username plus a cryptographic signature (HMAC-SHA256), and the server scopes the user's sessions and history to that identity, so prior messages can reappear across visits and devices.

## When to use secure mode

* You know who the end user is (logged-in app, Shopify admin, your dashboard's customer).
* You want chat history to persist per-user across page loads, devices, or browsers.
* You want to protect sessions from being impersonated by changing the `username` attribute.

If none of that matters, you don't need secure mode.

## Keys and secrets

Every assistant has a widget API key with two parts:

| Value      | Prefix  | Who sees it                                                      |
| ---------- | ------- | ---------------------------------------------------------------- |
| Public key | `yk_…`  | Embedded in HTML as the `key` attribute. Sent as `X-Yedric-Key`. |
| Secret     | `yks_…` | **Server-side only.** Never should be sent the browser.          |

{% hint style="warning" %}
Never put your `yks_…` secret in HTML, client-side JavaScript, or version control. Only the HMAC output (`signature`) is safe to render into the page. If a secret is ever exposed, rotate it immediately via **Agents → Widget → Code → Refresh key**.
{% endhint %}

Get the pair from **Agents → Widget → Code** in the dashboard, or create/refresh via the API.

### Domain restrictions

A widget key can be limited to a list of allowed domains. That check now **fails closed**: a request carrying no `Origin` or `Referer` header at all is rejected, where previously the check was skipped.

Browsers always send one of those headers, so ordinary embeds are unaffected. This does matter for anything calling the widget API outside a browser, such as a server-side script or an integration test, since those send no origin by default. Either send an `Origin` header that is on the list, or use a key with no domain restrictions for that caller. A key with an empty domain list stays unrestricted, so this only affects keys you have actually limited.

See [Yedric API Keys](broken://pages/lPr1RoK705tgoTZlaqne) and [Agents](broken://pages/F74G3aTMXvgDpJHdo93t) for the full key management flow.

## Signing the username

The signature is `HMAC-SHA256(secret, username)` encoded as lowercase hex.

```
signature = hex( HMAC-SHA256( key = "yks_your_secret", message = "jannette.parks@yedric.ai" ) )
```

The `username` is whatever stable identifier makes sense for your app: an email, a UUID, a Shopify shop UUID, a numeric user id, as long as the exact same string goes into the HMAC on the server and the `username` attribute on the widget.

### Node.js (server)

```js
import { createHmac } from 'node:crypto';

function signYedricUsername(secret, username) {
  return createHmac('sha256', secret).update(username).digest('hex');
}

const signature = signYedricUsername(
  process.env.YEDRIC_WIDGET_SECRET,
  req.user.email,
);
res.render('page', { yedricKey: process.env.YEDRIC_WIDGET_KEY, username: req.user.email, signature });
```

### PHP (server)

```php
$signature = hash_hmac('sha256', $username, $yedricSecret);
```

A real Shopify embed computes the signature from the shop UUID and passes the full config to the view:

```php
$uuid          = Request::Shop()->uuid;
$yedricSecret  = Config::get('yedric.widget_secret');   // load from env/config, never commit
$yedricKey     = Config::get('yedric.widget_key');
$yedricAgent   = Config::get('yedric.agent_id');

$yedric = [
    'enabled'   => !empty($yedricSecret) && !empty($yedricKey) && !empty($yedricAgent),
    'key'       => $yedricKey,
    'agent_id'  => $yedricAgent,
    'username'  => $uuid,
    'signature' => hash_hmac('sha256', $uuid, $yedricSecret),
];
```

Then the view renders:

```html
<?php if (!empty($yedric['enabled'])): ?>
  <script src="https://cdn.yedric.ai/widget.js?agent=<?= htmlspecialchars($yedric['agent_id'], ENT_QUOTES, 'UTF-8') ?>"></script>
  <yedric-widget
    key="<?= htmlspecialchars($yedric['key'], ENT_QUOTES, 'UTF-8') ?>"
    agent="<?= htmlspecialchars($yedric['agent_id'], ENT_QUOTES, 'UTF-8') ?>"
    username="<?= htmlspecialchars($yedric['username'], ENT_QUOTES, 'UTF-8') ?>"
    signature="<?= htmlspecialchars($yedric['signature'], ENT_QUOTES, 'UTF-8') ?>"
  ></yedric-widget>
<?php endif; ?>
```

### Browser SubtleCrypto (edge functions, workers)

Useful when your "server" is a Cloudflare Worker, Deno Deploy function, or anywhere without `node:crypto`.

```js
async function signYedricUsername(secret, username) {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw',
    enc.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const sigBytes = await crypto.subtle.sign('HMAC', key, enc.encode(username));
  return Array.from(new Uint8Array(sigBytes))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}
```

Never run this in the end-user's browser, because it needs the raw secret.

## Wiring it to the widget

Render the signed bundle into HTML attributes:

```html
<script src="https://cdn.yedric.ai/widget.js?agent=agt_123"></script>
<yedric-widget
  key="yk_abc…"
  agent="agt_123"
  username="jannette.parks@yedric.ai"
  signature="a1b2c3…"
></yedric-widget>
```

## Carrying a conversation across widget instances

Each `<yedric-widget>` is its own instance. A second widget (on another page, or in a separate iframe such as a fullscreen editor opened from a list view) loads the user's **default** thread on mount, not whatever conversation was active in the first instance. To carry the active conversation across, hand off its session id.

1. **Read the active session id** on the source page:

   ```js
   const sessionId = window.Yedric.getSessionId(); // secure session id, or null before a session exists
   ```
2. **Mount the second instance with that id** via the `resume-session-id` attribute. Because it's an attribute, you can server-render it into the iframe URL or set it before the element connects:

   ```html
   <yedric-widget
     key="yk_abc…"
     agent="agt_123"
     username="user@example.com"
     signature="a1b2c3…"
     resume-session-id="widget-<derived>-<thread>"
   ></yedric-widget>
   ```

   The second instance resumes that exact thread instead of the user's default thread. The server still validates that the thread belongs to the verified user.
3. **Re-sync the first instance** when the second one closes, so messages added in it appear:

   ```js
   window.Yedric.refresh(); // re-fetch the active secure thread's messages
   ```

`getSessionId()` and `refresh()` are also available on the element (`document.querySelector('yedric-widget')`). All three are secure-mode features: `getSessionId()` falls back to the anonymous session id outside secure mode, and `refresh()` is a no-op when there is no active secure session.

## Common pitfalls

* **Never ship `yks_…` to the browser.** Only the HMAC output (`signature`) goes into HTML. If your secret is ever exposed, rotate it via `POST /api/agents/:id/key/refresh`.
* **Username must match exactly.** A trailing space, different capitalization, or a different field (`id` vs `email`) will produce a different HMAC and a `401 Invalid signature` error. Log the exact string you pass to the HMAC and to the `username` attribute. It must be byte-for-byte identical.
* **One signature per username, not per session.** You don't re-sign for every page or session; the signature is static until the username changes.
* **Storing the secret in version control.** Prefer env vars or a secrets manager. Even in a private repo, committing `yks_…` makes rotation painful and broadens the blast radius of a leak.
* **Session prefix matters.** `session-id-prefix="preview"` scopes secure sessions under a different prefix than the default `widget`. A user's "widget" history and "preview" history are separate, so don't mix them.
* **Key/secret pair mismatch.** If you rotate the assistant's key but forget to redeploy the new secret on your signing server, every request will return `401 Invalid signature`. Keep them in sync.
