# Widget Identity Verification

Widget Identity Verification lets your website's backend securely tell the Karmaflow.ai chat widget **who is logged in**. Once a session is verified, the chat agent greets the visitor by name, never asks them to identify themselves, and the conversation is linked to their CRM contact from the very first message.

The integration is **server-to-server**: your backend calls the Karmaflow.ai claim API directly. Identity information and credentials **never pass through the visitor's browser**, and there are no frontend changes to make — the widget snippet you already installed keeps working exactly as-is on logged-out pages.

## How It Works

```
Visitor's browser
  │  The widget stores its session id in a FIRST-PARTY cookie on YOUR domain:
  │  __Host-KarmaSessionId_<agentId>
  │  → your backend receives it automatically on every same-site request
  ▼
Your backend (user is logged in)
  │  POST https://chat.karmaflow.ai/api/identity/claim?tenantId=YOUR_TENANT_ID
  │  Authorization: Bearer kfwi_...        (your Widget Identity secret)
  │  { "sessionId": "<from cookie>", "agentId": "YOUR_AGENT_ID",
  │    "user": { "id": "...", "email": "...", "name": "..." } }
  ▼
Karmaflow.ai
     Verifies your secret, binds the identity to the chat session,
     links/creates the CRM contact, and gives the agent the verified
     identity on every subsequent message.
```

Because the claim is authenticated with a secret only your server holds, the identity is **cryptographically attested by your website** — a visitor cannot spoof it by typing something into the chat.

## Prerequisites

1. **Generate your Widget Identity secret.** A tenant admin does this in the Karmaflow.ai dashboard under **Tenant Settings → Widget Identity Verification**. The secret (`kfwi_...`) is shown **once** — store it in your backend's secrets manager, e.g. as `KARMAFLOW_WIDGET_IDENTITY_SECRET`. It must never appear in frontend code, mobile apps, or version control.
2. **Find your IDs.** Your `tenantId` and `agentId` are both in the widget embed snippet (on the agent's **Embed** tab): `.../chat-widget.js?tenantId=...&agentId=...`.
3. **Have the widget installed.** The standard embed snippet must already be on your site — it is what creates the session cookie your backend reads.

## The Session Cookie

The widget stores its chat session id in a first-party cookie **on your domain**:

```
__Host-KarmaSessionId_<agentId>
```

Because it is first-party, your backend receives it automatically in the `Cookie` header of every same-site request — no frontend work needed.

The `__Host-` prefix is a deliberate security measure: browsers refuse to store a `__Host-` cookie that carries a `Domain` attribute, so a subdomain of your site can never plant or override this cookie (see the Threat Model section). On non-HTTPS pages — typically local development — the prefix is not allowed by browsers, and the widget falls back to the un-prefixed name `KarmaSessionId_<agentId>`. The same un-prefixed name is used when the agent has **Share Session Across Subdomains** enabled (see Login on a Different Subdomain), since a domain-scoped cookie cannot carry the prefix either. **Your middleware should read the `__Host-` name first and fall back to the plain name**, as the code samples below do — that covers every mode.

> **Important:** always read the session id from the **request cookie**. Never accept it from an untrusted request body or query parameter — reading it from the cookie pins the claim to the browser that actually owns the session.

If the cookie is absent (the visitor's very first page view, before the widget script has run), simply skip the claim and try again on their next request.

## Login on a Different Subdomain

Many sites authenticate on one subdomain and serve the signed-in app on another — say `login.example.com` redirecting to `my.example.com` after sign-in. By default the session cookie is **host-only** (the `__Host-` prefix makes a `Domain` attribute impossible — see the Threat Model section), so **each subdomain holds its own, different session id**. A claim made with the *login* subdomain's session id binds the identity to a session the visitor's chat never uses: the claim returns `200`, but over on the app subdomain the widget mints a fresh session id of its own, and the agent still asks who the user is.

You have two ways to make identity land on the session the visitor actually chats with:

### Option A — share one session across your subdomains (recommended for multi-subdomain sites)

Set **Share Session Across Subdomains** to your root domain (e.g. `example.com`) on the agent's **Embed** tab, under **Embed & Notifications**. The widget then scopes its session cookie to `Domain=example.com`, so **every subdomain sees the same session id**:

- The visitor's conversation follows them across subdomains — a chat started on `www` is still there on `my`.
- Your claim middleware works no matter which subdomain's backend runs it, because they all read the same cookie.
- The cookie name changes for this agent: `KarmaSessionId_<agentId>` (the `__Host-` prefix is not allowed on domain-scoped cookies). **Read that name first once sharing is on.** The shared cookie is the live session; a `__Host-KarmaSessionId_<agentId>` cookie left over from before you enabled sharing is stale, and it stays in the browser on any subdomain where the widget has not loaded since — the widget removes it the next time it loads there. The samples below read `__Host-` first, which is right for the default host-only cookie; swap the two lines for an agent that shares its session.

**The security trade, stated plainly:** the `__Host-` prefix is what stops one subdomain from planting session ids into browsers visiting your other subdomains ("cookie tossing"). Enabling the shared cookie gives that up, so only enable it if you control every subdomain of the configured domain — no user-generated-content hosts, no forgotten third-party-operated subdomains. Identity itself stays fully protected either way: binding a user to a session always requires your secret, server-to-server, and a session verified for one user is never re-bound to another. See the Threat Model section.

### Option B — keep host-only cookies, claim from the right backend

Keep the default hardened cookie and **make the claim from the backend that serves the pages where the visitor actually chats**:

- Run the claim middleware on the app backend (`my.example.com` in the example). On the first authenticated request after the login redirect, it reads that subdomain's own session cookie and claims the session the chat really uses. The middleware samples below already work exactly this way — the login subdomain does not need to participate at all.
- Don't embed the widget on a login-only subdomain where nobody chats. Every page load with the widget mints a session id for that host; on a pass-through login page that is a wasted chat session.
- Send the logout `DELETE` from the same app backend, with the same cookie.

This keeps the strongest cookie posture, but the conversation does not follow the visitor across subdomains — each subdomain has its own chat thread.

With either option, watch the claim response's `sessionExisted` field to confirm you are claiming the right ids: `false` occasionally is normal (your claim beat the visitor's first message), but `false` on **every** claim means you are claiming session ids no widget uses — almost always this subdomain split, unresolved.

## API Reference

Base URL: `https://chat.karmaflow.ai`

### Claim a session — `POST /api/identity/claim?tenantId=YOUR_TENANT_ID`

**Headers**

| Header | Value |
| --- | --- |
| `Authorization` | `Bearer kfwi_<your secret>` |
| `Content-Type` | `application/json` |

**Body**

```json
{
  "sessionId": "d3b07384-d9a1-4f5c-8f2e-1c9a2b3c4d5e",
  "agentId": "YOUR_AGENT_ID",
  "user": {
    "id": "10482",
    "email": "jane@example.com",
    "name": "Jane Doe",
    "phone": "+1 555 010 0100",
    "attrs": { "plan": "pro", "seats": 12, "vip": true }
  }
}
```

| Field | Required | Limits |
| --- | --- | --- |
| `sessionId` | yes | ≤128 chars — read from the session cookie (see above) |
| `agentId` | recommended | the agent id from your embed snippet |
| `user.id` | yes | your stable user id, coerced to string, ≤128 chars |
| `user.email` / `user.name` / `user.phone` | no | strings, ≤256 chars each |
| `user.attrs` | no | ≤10 keys; string/number/boolean values only, strings ≤256 chars |

Total request body must be under 8 KB.

**Responses**

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `{ "claimed": true, "sessionId": "...", "sessionExisted": true }` | Identity bound. `sessionId` **differs** from the one you sent when the session was already verified for a *different* user — Karmaflow.ai rotates instead of rebinding (see Logout & Shared Browsers below). No action needed; the widget adopts the new session automatically. `sessionExisted: false` means the claim arrived before the session had ever been used and Karmaflow.ai created it — normal occasionally, but if it is `false` on every claim you are claiming ids no widget uses (see Login on a Different Subdomain). |
| `400` | `{ "error": "invalid_claim", "reason": "..." }` | Malformed or oversized claim (`invalid_session_id`, `invalid_user_id`, `invalid_email`, `too_many_attrs`, `invalid_attrs`, `invalid_agent_id`, ...). Nothing was saved — claims are never partially accepted. |
| `401` | `{ "error": "invalid_secret" }` | Wrong, rotated-out, or missing secret. |
| `403` | `{ "error": "not_configured" }` | Widget Identity Verification is not enabled for your tenant (no secret generated, or disabled). |
| `403` | `{ "error": "server_to_server_only" }` | The request carried an `Origin` header — it came from a browser. The claim API must only be called from your backend. |
| `429` | `{ "error": "limit_reached" }` or rate-limit response | Rate limit exceeded, or your chat-session quota for the billing period is exhausted. |

### Unbind on logout — `DELETE /api/identity/claim?tenantId=YOUR_TENANT_ID`

Same headers. Body:

```json
{ "sessionId": "<from cookie>", "agentId": "YOUR_AGENT_ID" }
```

Some HTTP clients send no body on `DELETE` — in .NET, `HttpClient.DeleteAsync(url)` never does, and the request arrives empty. Either attach the body explicitly or pass the same two values as query parameters: `DELETE /api/identity/claim?tenantId=YOUR_TENANT_ID&sessionId=<from cookie>&agentId=YOUR_AGENT_ID`. A request with neither is rejected with `400 invalid_session_id`.

Returns `200 { "cleared": true, "sessionId": "<new anonymous session id>" }` (or `"cleared": false` if the session had no verified identity). The old session is retired and the visitor's widget starts a fresh anonymous conversation — the next person on that browser never sees the previous user's chat.

**Treat logout as must-succeed:** verify the response says `"cleared": true` (or `false`, meaning nothing was bound), retry once on network failure, and log any final failure. A logout that silently fails leaves the identity bound until the server-side backstops expire it (see Logout & Shared Browsers). The samples below implement this.

## Claim Caching

Claim once per **(chat session, user)** — not on every request — but cache that "already claimed" flag for **at most 24 hours**. Karmaflow.ai expires a verified identity **72 hours after the last claim** as a backstop against lost logouts, and each re-claim resets that clock; a ≤24h cache means an actively logged-in user is silently re-claimed long before expiry. The samples below implement this.

## Code Examples

Each sample: reads the cookie with fallback, claims once per (session, user) with a 24h cache, fires the claim **without blocking the response** where the stack makes that easy, uses short timeouts, and verifies logout.

### Node / Express

```js
const KF = {
  base: 'https://chat.karmaflow.ai',
  tenantId: process.env.KARMAFLOW_TENANT_ID,
  agentId: process.env.KARMAFLOW_AGENT_ID,
  secret: process.env.KARMAFLOW_WIDGET_IDENTITY_SECRET,
};

const KF_CLAIM_TTL_MS = 24 * 60 * 60 * 1000; // re-claim daily (server expires at 72h)

function kfSessionId(req) {
  return req.cookies['__Host-KarmaSessionId_' + KF.agentId]
      || req.cookies['KarmaSessionId_' + KF.agentId]; // non-HTTPS dev fallback
}

// Claim middleware — place after your auth + cookie-parser + session middleware.
// Fire-and-forget: never blocks the page response; chat simply stays anonymous
// until a claim succeeds.
app.use((req, res, next) => {
  const sessionId = kfSessionId(req);
  const claimKey = sessionId && req.user ? `${sessionId}:${req.user.id}` : null;
  const cached = req.session.kfClaim;

  if (claimKey && (!cached || cached.key !== claimKey || Date.now() - cached.at > KF_CLAIM_TTL_MS)) {
    req.session.kfClaim = { key: claimKey, at: Date.now() }; // optimistic; reset on failure
    fetch(`${KF.base}/api/identity/claim?tenantId=${KF.tenantId}`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${KF.secret}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        sessionId,
        agentId: KF.agentId,
        user: {
          id: String(req.user.id),
          email: req.user.email,
          name: req.user.fullName,
          attrs: { plan: req.user.plan },
        },
      }),
      signal: AbortSignal.timeout(2000),
    }).then(async (r) => {
      if (!r.ok) {
        req.session.kfClaim = null;
        console.warn('Karmaflow claim failed:', r.status, await r.text().catch(() => ''));
      }
    }).catch(() => { req.session.kfClaim = null; });
  }
  next();
});

// On logout — await this one and verify it landed.
async function karmaflowLogout(req) {
  const sessionId = kfSessionId(req);
  if (!sessionId) return;
  for (let attempt = 0; attempt < 2; attempt++) {
    try {
      const r = await fetch(`${KF.base}/api/identity/claim?tenantId=${KF.tenantId}`, {
        method: 'DELETE',
        headers: {
          Authorization: `Bearer ${KF.secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ sessionId, agentId: KF.agentId }),
        signal: AbortSignal.timeout(2000),
      });
      const data = await r.json().catch(() => ({}));
      if (r.ok && (data.cleared === true || data.cleared === false)) return;
    } catch (e) { /* retry once */ }
  }
  console.error('Karmaflow logout unbind FAILED for session', sessionId);
}
```

### ASP.NET Core (C#)

```csharp
// Program.cs: builder.Services.AddHttpClient("karmaflow", c => c.Timeout = TimeSpan.FromSeconds(2));
//             app.UseMiddleware<KarmaflowIdentityMiddleware>(); // after UseAuthentication() + UseSession()
public class KarmaflowIdentityMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IHttpClientFactory _http;
    private static readonly string Base = "https://chat.karmaflow.ai";
    private static readonly string TenantId = Environment.GetEnvironmentVariable("KARMAFLOW_TENANT_ID")!;
    private static readonly string AgentId = Environment.GetEnvironmentVariable("KARMAFLOW_AGENT_ID")!;
    private static readonly string Secret = Environment.GetEnvironmentVariable("KARMAFLOW_WIDGET_IDENTITY_SECRET")!;
    private static readonly TimeSpan ClaimTtl = TimeSpan.FromHours(24); // server expires at 72h

    public KarmaflowIdentityMiddleware(RequestDelegate next, IHttpClientFactory http)
    { _next = next; _http = http; }

    private static string? SessionId(HttpContext ctx) =>
        ctx.Request.Cookies[$"__Host-KarmaSessionId_{AgentId}"]
        ?? ctx.Request.Cookies[$"KarmaSessionId_{AgentId}"]; // non-HTTPS dev fallback

    public async Task InvokeAsync(HttpContext ctx)
    {
        var sessionId = SessionId(ctx);
        var userId = ctx.User.Identity?.IsAuthenticated == true
            ? ctx.User.FindFirst("sub")?.Value : null;

        if (sessionId != null && userId != null)
        {
            var claimKey = $"{sessionId}:{userId}";
            var cached = ctx.Session.GetString("kfClaim");
            var stale = cached == null || !cached.StartsWith(claimKey + "|")
                || DateTimeOffset.UtcNow - DateTimeOffset.Parse(cached.Split('|')[1]) > ClaimTtl;
            if (stale)
            {
                try
                {
                    var client = _http.CreateClient("karmaflow");
                    var req = new HttpRequestMessage(HttpMethod.Post,
                        $"{Base}/api/identity/claim?tenantId={TenantId}");
                    req.Headers.Authorization = new("Bearer", Secret);
                    req.Content = JsonContent.Create(new
                    {
                        sessionId,
                        agentId = AgentId,
                        user = new
                        {
                            id = userId,
                            email = ctx.User.FindFirst(ClaimTypes.Email)?.Value,
                            name = ctx.User.Identity?.Name,
                        },
                    });
                    var res = await client.SendAsync(req);
                    if (res.IsSuccessStatusCode)
                        ctx.Session.SetString("kfClaim", $"{claimKey}|{DateTimeOffset.UtcNow:O}");
                }
                catch { /* non-fatal: chat continues anonymously */ }
            }
        }
        await _next(ctx);
    }

    // On logout: send HttpMethod.Delete to the same URL with { sessionId, agentId } as
    // Content = JsonContent.Create(...) (DeleteAsync(url) sends no body), or as query
    // parameters; verify the JSON response contains "cleared", retry once, and log a
    // final failure.
}
```

### PHP (Laravel)

```php
// app/Http/Middleware/KarmaflowIdentity.php — register after auth + session middleware.
class KarmaflowIdentity
{
    public function handle(Request $request, Closure $next)
    {
        $agentId = config('services.karmaflow.agent_id');
        $sessionId = $request->cookie("__Host-KarmaSessionId_{$agentId}")
            ?? $request->cookie("KarmaSessionId_{$agentId}"); // non-HTTPS dev fallback
        $user = $request->user();
        $claimKey = $sessionId && $user ? "{$sessionId}:{$user->id}" : null;
        $cached = session('kf_claim'); // ['key' => ..., 'at' => timestamp]
        $stale = !$cached || $cached['key'] !== $claimKey || (time() - $cached['at']) > 86400;

        if ($claimKey && $stale) {
            try {
                $response = Http::withToken(config('services.karmaflow.widget_identity_secret'))
                    ->timeout(2)
                    ->post('https://chat.karmaflow.ai/api/identity/claim?tenantId=' . config('services.karmaflow.tenant_id'), [
                        'sessionId' => $sessionId,
                        'agentId' => $agentId,
                        'user' => [
                            'id' => (string) $user->id,
                            'email' => $user->email,
                            'name' => $user->name,
                        ],
                    ]);
                if ($response->successful()) {
                    session(['kf_claim' => ['key' => $claimKey, 'at' => time()]]);
                } else {
                    Log::warning('Karmaflow claim failed', ['status' => $response->status()]);
                }
            } catch (\Throwable $e) {
                // Non-fatal: chat continues anonymously.
            }
        }
        return $next($request);
    }
}

// On logout — verify and retry once:
foreach ([1, 2] as $attempt) {
    try {
        $r = Http::withToken(config('services.karmaflow.widget_identity_secret'))
            ->timeout(2)
            ->delete('https://chat.karmaflow.ai/api/identity/claim?tenantId=' . config('services.karmaflow.tenant_id'), [
                'sessionId' => $sessionId,
                'agentId' => $agentId,
            ]);
        if ($r->successful() && $r->json('cleared') !== null) { break; }
    } catch (\Throwable $e) { /* retry once */ }
    if ($attempt === 2) { Log::error('Karmaflow logout unbind FAILED', ['sessionId' => $sessionId]); }
}
```

Plain PHP (no framework): read `$_COOKIE["__Host-KarmaSessionId_" . $agentId] ?? $_COOKIE["KarmaSessionId_" . $agentId]`, send the same JSON with `curl_init()` and an `Authorization: Bearer` header, and remember the claim key + timestamp in `$_SESSION['kf_claim']`.

### Python (Django)

```python
# middleware.py — add after AuthenticationMiddleware and SessionMiddleware.
import os, time, logging, requests

log = logging.getLogger(__name__)
KF_BASE = "https://chat.karmaflow.ai"
KF_TENANT_ID = os.environ["KARMAFLOW_TENANT_ID"]
KF_AGENT_ID = os.environ["KARMAFLOW_AGENT_ID"]
KF_SECRET = os.environ["KARMAFLOW_WIDGET_IDENTITY_SECRET"]
KF_CLAIM_TTL = 24 * 60 * 60  # re-claim daily (server expires at 72h)

def kf_session_id(request):
    return (request.COOKIES.get(f"__Host-KarmaSessionId_{KF_AGENT_ID}")
            or request.COOKIES.get(f"KarmaSessionId_{KF_AGENT_ID}"))  # non-HTTPS dev fallback

class KarmaflowIdentityMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        session_id = kf_session_id(request)
        if session_id and request.user.is_authenticated:
            claim_key = f"{session_id}:{request.user.pk}"
            cached = request.session.get("kf_claim") or {}
            if cached.get("key") != claim_key or time.time() - cached.get("at", 0) > KF_CLAIM_TTL:
                try:
                    r = requests.post(
                        f"{KF_BASE}/api/identity/claim",
                        params={"tenantId": KF_TENANT_ID},
                        headers={"Authorization": f"Bearer {KF_SECRET}"},
                        json={
                            "sessionId": session_id,
                            "agentId": KF_AGENT_ID,
                            "user": {
                                "id": str(request.user.pk),
                                "email": request.user.email,
                                "name": request.user.get_full_name(),
                            },
                        },
                        timeout=2,
                    )
                    if r.ok:
                        request.session["kf_claim"] = {"key": claim_key, "at": time.time()}
                    else:
                        log.warning("Karmaflow claim failed: %s %s", r.status_code, r.text[:200])
                except requests.RequestException:
                    pass  # non-fatal: chat continues anonymously
        return self.get_response(request)

# On logout — verify and retry once:
def karmaflow_logout(request):
    session_id = kf_session_id(request)
    if not session_id:
        return
    for _ in range(2):
        try:
            r = requests.delete(
                f"{KF_BASE}/api/identity/claim",
                params={"tenantId": KF_TENANT_ID},
                headers={"Authorization": f"Bearer {KF_SECRET}"},
                json={"sessionId": session_id, "agentId": KF_AGENT_ID},
                timeout=2,
            )
            if r.ok and "cleared" in r.json():
                return
        except requests.RequestException:
            pass
    log.error("Karmaflow logout unbind FAILED for session %s", session_id)
```

Flask: do the same inside a `@app.before_request` handler, reading `request.cookies.get(...)` and caching the claim key + timestamp in Flask's `session`. If your stack has a task queue (Celery, RQ), you can enqueue the claim instead of calling inline — see Performance below.

### curl (raw HTTP)

```bash
# Claim
curl -X POST "https://chat.karmaflow.ai/api/identity/claim?tenantId=YOUR_TENANT_ID" \
  -H "Authorization: Bearer $KARMAFLOW_WIDGET_IDENTITY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "sessionId": "d3b07384-d9a1-4f5c-8f2e-1c9a2b3c4d5e",
    "agentId": "YOUR_AGENT_ID",
    "user": { "id": "10482", "email": "jane@example.com", "name": "Jane Doe" }
  }'
# → 200 {"claimed":true,"sessionId":"d3b07384-...","sessionExisted":true}

# Logout
curl -X DELETE "https://chat.karmaflow.ai/api/identity/claim?tenantId=YOUR_TENANT_ID" \
  -H "Authorization: Bearer $KARMAFLOW_WIDGET_IDENTITY_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "sessionId": "d3b07384-d9a1-4f5c-8f2e-1c9a2b3c4d5e", "agentId": "YOUR_AGENT_ID" }'
# → 200 {"cleared":true,"sessionId":"<new anonymous session id>"}

# Wrong secret            → 401 {"error":"invalid_secret"}
# Missing user.id         → 400 {"error":"invalid_claim","reason":"invalid_user_id"}
# Feature not enabled     → 403 {"error":"not_configured"}
# Called from a browser   → 403 {"error":"server_to_server_only"}
```

## What the Agent Does With a Verified Identity

- **Greets the visitor by name** and never asks them to identify themselves — no more "what's your name and email?" at the start of every chat.
- **Links the conversation to their CRM contact immediately** (matched by your user id, then email), so conversation history, memory, and CRM tools work from the first message.
- **Protects verified fields.** The verified name, email, and phone are authoritative: nothing the visitor types into the chat can overwrite them. Extra details the visitor shares are still saved additively.
- Custom `attrs` you send (plan, role, region, ...) are visible to the agent as context.

## Logout & Shared Browsers

- **On logout**, send the `DELETE` claim and verify it succeeded (samples above). The session is retired and replaced with a fresh anonymous one — the widget picks this up automatically and clears its transcript.
- **If a different user logs in on the same browser** (shared or public computer) and your backend claims the same session for the new user id, Karmaflow.ai automatically **rotates the session** rather than rebinding it: the response's `sessionId` is a new session for the new user, the old conversation stays private to the previous user, and the widget adopts the new session on its own. A session verified for user A is never silently re-bound to user B.
- **Backstops if a logout is lost:** three server-side mechanisms bound the exposure even when the DELETE never arrives — the identity-switch rotation above (the next *login* on that browser rotates), sessions rotate to anonymous after **10 hours of inactivity**, and every verified identity **expires 72 hours after its last claim** (each re-claim resets the clock — hence the 24h claim cache).

## Performance

The claim is one HTTPS round trip, and thanks to the claim cache it fires roughly **once per visitor session** — but that first call sits in your request path if you await it.

- **Prefer fire-and-forget** for the claim (as in the Node sample): don't block your page response on it. The failure mode is benign — chat stays anonymous until a claim succeeds on a later request.
- **Keep timeouts short** (~2 seconds). A slow claim should never slow your site.
- **Exclude latency-sensitive routes** (JSON APIs, health checks, static assets) from the middleware; the claim only needs to run on page-serving requests.
- If you already run a task queue, enqueueing the claim is the cleanest option.

## Threat Model

What the session cookie is — and is not: the cookie is a **session correlator**, not a credential. The only credential in this design is your Widget Identity secret, which never leaves your server. Someone who learns a session id cannot claim it (claiming requires your secret); the risks worth understanding are about *which* session id your backend claims.

**What the platform enforces:**

- **Unguessable session ids** — generated with the browser's cryptographic random number generator.
- **`__Host-` cookie prefix** (default) — browsers refuse `__Host-` cookies set with a `Domain` attribute, so no subdomain of your site (a marketing microsite, a user-content subdomain, a compromised third-party subdomain) can plant a session id into your users' browsers. This closes the classic "cookie tossing" session-fixation vector. Enabling **Share Session Across Subdomains** opts out of this specific protection for that agent — the worst case is a hostile subdomain eavesdropping on a chat, never impersonation — so enable it only when you control every subdomain (see Login on a Different Subdomain).
- **Rotation on conflict, never rebinding** — a session already verified for one user is never re-bound to another; a new user gets a new session.
- **Server-to-server only** — claims carrying a browser `Origin` header are rejected, so the secret cannot be used from frontend code even by mistake.
- **Strict validation** — malformed claims are rejected outright, never partially applied; claims are rate-limited; the secret is stored hash-only and rotatable with a 24h dual-validity window.

**Why the cookie is not `HttpOnly`:** the widget's own JavaScript creates and maintains the session cookie — that is what makes the integration require zero frontend changes. An `HttpOnly` cookie cannot be written by JavaScript, so it is not an option for this design; the `__Host-` prefix and unguessable ids are the compensating controls.

**Out of scope — XSS on your own site:** if an attacker can run script on your pages, they can already read the chat window, act as the user on your site, and steal their session with you — no chat-identity design survives that. Standard XSS hygiene (CSP, output encoding, dependency auditing) is the control, and it protects far more than the chat widget.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `401 invalid_secret` | Wrong secret, or the previous secret expired (>24h after rotation) | Copy the current secret from Tenant Settings → Widget Identity Verification; check for whitespace and the `kfwi_` prefix. |
| `403 not_configured` | No secret generated, or the feature is disabled | A tenant admin must generate/enable the secret in the dashboard. |
| `403 server_to_server_only` | The call was made from a browser (an `Origin` header was present) | Move the call to your backend. The secret must never be in frontend code — if it was exposed, rotate it immediately. |
| `400 invalid_claim` | Missing `user.id`, oversized fields, >10 attrs, or non-scalar attr values | Check the `reason` field against the limits table above. |
| `429` | Rate limit or chat-session quota reached | Ensure you claim once per session with the 24h cache, not per request; check your plan's chat session quota. |
| Cookie missing on the visitor's first page view | The widget script hadn't run yet when the request was made | Expected — claim on the visitor's next request (the middleware pattern handles this automatically). |
| Cookie present locally but not in production (or vice versa) | The cookie name differs by protocol: `__Host-KarmaSessionId_<agentId>` on HTTPS, `KarmaSessionId_<agentId>` on non-HTTPS dev pages | Read both names, `__Host-` first (all samples above do). |
| Cookie never reaches your API | Your API lives on a different origin (e.g. `api.example.com`) than the site (`example.com`), so the cookie isn't sent cross-origin | Forward the cookie's value from your frontend to your backend explicitly (e.g. in a request header). The claim itself must still be made by your backend with the secret. |
| Agent still asks who the user is | Claim succeeded after the message was already sent, claim failed silently, or the identity expired (no re-claim within 72h) | Verify the claim returns `200`; claim early in the page lifecycle; make sure your claim cache re-claims within 24h. |
| Claims return `200` but the agent never recognizes anyone; login and app live on different subdomains (e.g. `login.example.com` → `my.example.com`) | The session cookie is host-only by default, so each subdomain has its own session id — the claim used the login subdomain's id while the chat runs under the app subdomain's | Enable **Share Session Across Subdomains** on the agent's Embed tab, or move the claim middleware to the backend serving the pages where visitors chat (see Login on a Different Subdomain for both options). `sessionExisted: false` on every claim response confirms this is what's happening. |
| User appears logged in to chat after logging out of your site | The logout DELETE failed silently | Verify `cleared` in the response and retry (samples above). Backstops cap the exposure: next login rotates, 10h inactivity rotates, 72h TTL expires the identity. |

## Quick Checklist

1. Generate the secret (Tenant Settings → Widget Identity Verification) and store it as `KARMAFLOW_WIDGET_IDENTITY_SECRET`.
2. Add the claim middleware to your backend (samples above), reading `__Host-KarmaSessionId_<agentId>` (fallback `KarmaSessionId_<agentId>`) from request cookies, with a ≤24h claim cache.
3. Add the verified `DELETE` claim to your logout flow.
4. Log in on your site, open the chat, and confirm the agent greets you by name.
