Reference
Authentication & security
Publishable keys, origin allowlists, identity JWTs, session tokens, CSP, rate limits, rotation, PII and the threat model.
Publishable keys and the origin allowlist
An embed key (hive_pk_ + 43 URL-safe characters, format ^hive_pk_[A-Za-z0-9_-]{43}$) is designed to be visible in your page source. It is:
- bound to one agent — it cannot reach any other agent, thread, file or tenant data;
- bound to an origin allowlist (up to 50 entries). Origins are exact — scheme, host and port, no path. Every request carries the browser’s
Origin; a mismatch is a403 EMBED_ORIGIN_DENIEDsent withoutAccess-Control-Allow-Origin, so a foreign page cannot even read the response (theOPTIONSpreflight itself answers204; enforcement is on the actual request). An empty list allows nothing. The single wildcard entry*allows every origin — acceptable while prototyping, not in production; - hashed at rest (SHA-256) — the console shows the full key once, at creation or rotation. Afterwards only the prefix
hive_pk_xxxxxxxx…is visible; - rate-limited and switchable —
rate_limit_per_min,enabled, rotate, revoke (below).
Someone who copies the key from your page can do exactly what a visitor of your site can do: send rate-limited messages to that one agent. They cannot read other visitors’ conversations, cannot change configuration and cannot use the key to call any other API.
Identified end users (identity JWT)
By default visitors are anonymous (a random visitor id). To attach conversations to your signed-in users, enable Identify signed-in users on the key. The console issues an identity secret — a 43-character base64url string, shown once — that only your backend may hold. Your backend signs a short-lived HS256 JWT with it and your page passes the token to the widget as user: { token }.
Claims
| Claim | Type | Required | Description |
|---|---|---|---|
sub | string 1–256 | yes | Your user id → end_users.external_id (upserted per tenant). |
exp | integer (unix seconds) | yes | Expiry. Must be in the future and at most 24 h ahead. |
iat | integer (unix seconds) | no | Issued-at. Rejected (iat_in_future) when more than 5 minutes ahead of the server clock. |
nbf | integer (unix seconds) | no | Not-before. Honored with 60 s of clock skew; rejected (not_yet_valid) beyond that. |
name | string ≤ 200 | no | Display name shown in the console Contacts page. |
email | email ≤ 320 | no | Stored on the contact with a pii_tags: ["email"] marker. |
meta | object ≤ 16 keys; values string ≤ 500 | number | boolean | no | Free-form attributes stored under end_users.attributes.embed. |
- Header must be
{"alg":"HS256","typ":"JWT"}(typmay be omitted). Any otheralg— includingnone— is rejected. expis required, must be in the future and at most 24 hours ahead. One hour is a sensible default; the widget sends the token only when it mints a session, so short lifetimes cost nothing.iatandnbfare optional but checked when present:iatmore than 300 s in the future ornbfmore than 60 s in the future is rejected. Keep your server clock in sync (NTP).- The signature is HMAC-SHA256 over
base64url(header).base64url(payload)with the secret string’s UTF-8 bytes as the key — pass the secret to your JWT library as-is; do not base64-decode it. - Whole token ≤ 4096 characters. Unknown claims are ignored;
metais limited to 16 keys with string (≤ 500 chars), number or boolean values.
Minting the token on your backend
Expose an authenticated endpoint that returns a token for the current user. Samples with no JWT library (hand-rolled HS256 is 6 lines) and with PyJWT. Omit optional claims rather than sending null — "email": null fails validation and the whole token is rejected with EMBED_IDENTITY_INVALID (reason: claims); every sample drops empty values before signing.
import express from 'express';
import { createHmac } from 'node:crypto';
const app = express();
const IDENTITY_SECRET = process.env.HIVE_EMBED_IDENTITY_SECRET!; // server-side only
const b64url = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
// Omit optional claims rather than sending null — "email": null is rejected.
const compact = <T extends object>(o: T) =>
Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null));
function mintHiveIdentity(user: { id: string; name: string; email?: string | null; plan?: string | null }) {
const now = Math.floor(Date.now() / 1000);
const header = b64url({ alg: 'HS256', typ: 'JWT' });
const payload = b64url(compact({
sub: user.id,
name: user.name,
email: user.email,
meta: user.plan ? { plan: user.plan } : undefined,
iat: now,
exp: now + 60 * 60, // 1 hour (max 24 h)
}));
const sig = createHmac('sha256', IDENTITY_SECRET).update(`${header}.${payload}`).digest('base64url');
return `${header}.${payload}.${sig}`;
}
// Behind your own session auth; returns the token for the current user.
app.get('/api/hive-identity', requireLogin, (req, res) => {
res.json({ token: mintHiveIdentity(req.user) });
});import { createHmac } from 'node:crypto';
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth'; // your session helper
export const runtime = 'nodejs';
const b64url = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
const now = Math.floor(Date.now() / 1000);
const header = b64url({ alg: 'HS256', typ: 'JWT' });
// Only include optional claims that have a value (null would be rejected).
const payload = b64url({
sub: user.id,
...(user.name ? { name: user.name } : {}),
...(user.email ? { email: user.email } : {}),
iat: now,
exp: now + 3600,
});
const sig = createHmac('sha256', process.env.HIVE_EMBED_IDENTITY_SECRET!)
.update(`${header}.${payload}`)
.digest('base64url');
return NextResponse.json({ token: `${header}.${payload}.${sig}` });
}import os, time
import jwt # pip install PyJWT
from fastapi import FastAPI, Depends
app = FastAPI()
IDENTITY_SECRET = os.environ["HIVE_EMBED_IDENTITY_SECRET"] # server-side only
def mint_hive_identity(user) -> str:
now = int(time.time())
claims = {
"sub": user.id,
"name": user.name,
"email": user.email, # may be None
"meta": {"plan": user.plan} if user.plan else None,
"iat": now,
"exp": now + 3600, # 1 hour (max 24 h)
}
# Omit optional claims rather than sending null: "email": null is rejected.
claims = {k: v for k, v in claims.items() if v is not None}
# PyJWT uses the secret string's bytes as the HMAC key — pass it as-is.
return jwt.encode(claims, IDENTITY_SECRET, algorithm="HS256")
@app.get("/api/hive-identity")
def hive_identity(user=Depends(current_user)):
return {"token": mint_hive_identity(user)}using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
[ApiController, Route("api/hive-identity")]
public class HiveIdentityController : ControllerBase
{
private static readonly string Secret =
Environment.GetEnvironmentVariable("HIVE_EMBED_IDENTITY_SECRET")!; // server-side only
private static string B64Url(byte[] b) =>
Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
// Omit optional claims rather than sending null: with the default options
// System.Text.Json writes "email": null, which the API rejects.
private static readonly JsonSerializerOptions Json = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
[HttpGet, Authorize]
public IActionResult Get()
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var header = B64Url(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }, Json));
var payload = B64Url(JsonSerializer.SerializeToUtf8Bytes(new
{
sub = User.FindFirst("sub")!.Value,
name = User.Identity?.Name, // null → omitted
email = User.FindFirst("email")?.Value, // null → omitted
iat = now,
exp = now + 3600, // 1 hour (max 24 h)
}, Json));
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
var sig = B64Url(hmac.ComputeHash(Encoding.ASCII.GetBytes($"{header}.{payload}")));
return Ok(new { token = $"{header}.{payload}.{sig}" });
}
}import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.*;
@RestController
public class HiveIdentityController {
private static final String SECRET = System.getenv("HIVE_EMBED_IDENTITY_SECRET"); // server-side only
private static final ObjectMapper JSON = new ObjectMapper();
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
@GetMapping("/api/hive-identity")
public Map<String, String> identity(@AuthenticationPrincipal AppUser user) throws Exception {
long now = System.currentTimeMillis() / 1000;
String header = B64.encodeToString(JSON.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT")));
// Omit optional claims rather than sending null (Map.of would also throw
// NullPointerException on a null value).
Map<String, Object> claims = new LinkedHashMap<>();
claims.put("sub", user.getId());
if (user.getName() != null) claims.put("name", user.getName());
if (user.getEmail() != null) claims.put("email", user.getEmail());
claims.put("iat", now);
claims.put("exp", now + 3600); // 1 hour (max 24 h)
String payload = B64.encodeToString(JSON.writeValueAsBytes(claims));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String sig = B64.encodeToString(mac.doFinal((header + "." + payload).getBytes(StandardCharsets.US_ASCII)));
return Map.of("token", header + "." + payload + "." + sig);
}
}// Browser: fetch the token from YOUR backend, then hand it to the widget.
const { token } = await fetch('/api/hive-identity', { credentials: 'include' }).then((r) => r.json());
const chat = HiveChat.init({
key: 'hive_pk_…',
user: { token }, // the widget sends it once, when minting the session
});
// Later (token about to expire, or user switched account):
chat.update({ user: { token: freshToken } }); // starts a new session for that user
chat.update({ user: undefined }); // back to an anonymous visitor| Error | Status | Meaning | Fix |
|---|---|---|---|
EMBED_IDENTITY_REQUIRED | 401 | The key has identity_required and the session request carried no identity JWT. | Mint an identity JWT on your backend for signed-in users and pass it as user: { token }. |
EMBED_IDENTITY_INVALID | 401 | The identity JWT was rejected; reason is one of malformed, alg, signature, expired, claims, exp_too_far, iat_in_future (iat more than 5 min ahead), not_yet_valid (nbf more than 60 s ahead). | Sign with HS256 and the key’s CURRENT identity secret; include sub and an exp at most 24 h ahead; keep server clocks in sync; keep meta ≤ 16 keys. |
EMBED_IDENTITY_UNAVAILABLE | 401 | An identity JWT was sent but the key has no identity secret configured. | Create the key with with_identity_secret: true or issue a secret via POST /v1/embed-keys/:id/identity-secret. |
EMBED_IDENTITY_INVALID carries a reason extension: malformed, alg, signature, expired, claims, exp_too_far, iat_in_future or not_yet_valid.
Session tokens
On page load the widget only fetches the public display config (GET /v1/embed/config, key in the X-Hive-Embed-Key header so it never lands in access logs). The first time the visitor opens the panel or sends a message it exchanges the key (and the optional identity JWT) for a session token via POST /v1/embed/session. The token is an HMAC-signed, opaque string valid for 15 minutes and is sent as Authorization: Bearer on every subsequent call. It is scoped to:
| Field | Scope |
|---|---|
v | Format version (1). |
t | Tenant id. |
k | Embed key id. |
a | Agent id. |
o | Origin the token was minted for (* when the key allows any origin and none was sent). |
vid | Visitor id. |
eu | End-user id when identified, else null. |
iat / exp | Issued-at / expiry (unix seconds; 15-minute lifetime). |
The widget refreshes it transparently (a re-mint when a call finds the token within 30 s of expiry, or one re-mint and retry after a 401) and never places it in a URL — which is also why the SSE endpoint cannot be consumed with EventSource.
What the browser holds — and what it does not
| In the browser | Never in the browser |
|---|---|
|
|
Content-Security-Policy
Two directives, both for the origin your snippet points at (https://console.agenticworkforce.me for the hosted platform):
Content-Security-Policy:
default-src 'self';
script-src 'self' https://console.agenticworkforce.me;
connect-src 'self' https://console.agenticworkforce.me;
# style-src: nothing to add — the widget uses a constructed stylesheet.
# img-src: nothing to add — no external images are loaded.Styles are installed with a constructed stylesheet (adoptedStyleSheets), which style-src does not govern — no 'unsafe-inline', no nonce, no hash. No frame-src (no iframes), no img-src (the agent avatar is text), no third-party origins. The only fallback:
# Only if you must support Safari < 16.4 under a strict style-src:
style-src 'self' 'unsafe-inline';Rate limits
| Bucket | Limit | Applies to |
|---|---|---|
| Config reads per IP | 60 / min | GET /v1/embed/config, per client IP. |
| Session mints per IP | 30 / min | POST /v1/embed/session, per client IP. |
| Session mints per key | 10 × rate_limit_per_min | POST /v1/embed/session, across all visitors of the key. |
| Messages per key | rate_limit_per_min (default 60, max 10,000) | POST /v1/embed/messages, across all visitors of the key. |
| Messages per visitor | 20 / min | POST /v1/embed/messages, per visitor (or identified user). |
| Reads per visitor | 60 / min | History, cancel and opening GET …/events, per visitor. |
| Live streams per visitor | 2 concurrent | Open GET …/events connections; a third one is 429 EMBED_TOO_MANY_STREAMS. |
| Messages per thread | 200 | Beyond that POST /v1/embed/messages answers 409 EMBED_THREAD_FULL; the visitor starts a new conversation. |
When a token bucket is exhausted the API answers:
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 42
{ "type": "about:blank", "title": "Too Many Requests", "status": 429,
"code": "RATE_LIMITED", "detail": "Embed rate limit exceeded. Try again shortly.",
"retry_after": 42 }The widget shows a retryable error bubble (code RATE_LIMITED) and keeps the thread; the visitor can send again after Retry-After. Adjust rate_limit_per_min on the key for legitimate high traffic; the per-IP, per-visitor, stream and thread guards are fixed. One reply at a time per thread is also enforced: sending while a run is active returns 409 EMBED_RUN_IN_FLIGHT. The platform’s global limiter still applies on top.
Key rotation, pausing and revocation
- Rotate issues a new publishable key and invalidates the old one immediately. Existing sessions keep working until they expire (≤ 15 min); the next mint with the old key fails with
401 EMBED_KEY_INVALID. Update your snippet, then rotate — or rotate first and accept a short outage. - Pause (
enabled: false) rejects every call with401 EMBED_KEY_DISABLEDwithout changing the key. Visitors see the widget’s offline notice; flipping it back needs no redeploy. - Revoke soft-deletes the key. Tokens minted with it stop working on their next request. Threads and audit rows are retained.
- Rotate the identity secret independently of the key. Old identity JWTs fail with
EMBED_IDENTITY_INVALID(signature).
All of these are one click in the console, or an API call:
# Rotate: the old key stops working immediately; the response carries the new one (once).
curl -X POST https://console.agenticworkforce.me/v1/embed-keys/{id}/rotate \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID"
# Pause without breaking anything (visitors see the offline notice; re-enable any time)
curl -X PATCH https://console.agenticworkforce.me/v1/embed-keys/{id} \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID" \
-H "Content-Type: application/json" \
-d '{"enabled": false}'
# Revoke for good (soft delete; sessions minted with it fail on their next call)
curl -X DELETE https://console.agenticworkforce.me/v1/embed-keys/{id} \
-H "Authorization: Bearer $HIVE_API_KEY" -H "X-Tenant-Id: $HIVE_TENANT_ID"Every create, update, rotate, secret issue and revoke is written to the tenant’s append-only audit log. See the admin API.
PII guidance
- Send only what you need in the identity token:
subis enough for continuity;name/emailmake the Contacts page useful;metais for routing attributes (plan, region), not for profiles. - Identity fields are stored on the contact (
end_users) with PII tags. They are never injected into prompts, run state or checkpoints. - Message content is what the visitor typed; it becomes a
usermessage on a governed run. Tell visitors not to paste secrets, and use the agent’s guardrails for input/output filtering. Message content is logged atdebuglevel only. - Anonymous visitor ids are random and stored client-side; use
storage: 'session'or'none'on shared devices (kiosks). - Data residency, retention and deletion follow your tenant’s platform agreement.
Threat model summary
Condensed from the platform specification (docs/07-embed.md §9):
| Threat | Control |
|---|---|
| Publishable key copied from page source | Meant to be public: bound to one agent and an origin allowlist, per-key rate limit, hashed at rest, rotate/revoke in one click. No tenant data is readable with it. |
| Origin spoofing (curl with a fake `Origin`) | Only allows what a visitor on the allowed site can do: send rate-limited messages. Non-browser callers cannot read cross-origin responses anyway; abuse is bounded by buckets and enabled: false. |
| Session token theft (XSS on the host page) | 15-minute TTL, bound to origin and visitor; cannot mint new tokens without the key; cannot read other visitors’ threads. Tokens never appear in URLs. |
| Cross-visitor thread access | Thread creator must equal the token subject and the thread’s agent must equal the token agent, else 404. |
| Cross-tenant access | Every query runs under the tenant of the token with row-level security forced on embed_keys; covered by the isolation test suite. |
| Abuse / cost | Per-key rate_limit_per_min (default 60), per-visitor 20 messages/min and 60 reads/min, per-IP config reads 60/min and session mints 30/min, 2 concurrent streams per visitor, 200 messages per thread, one in-flight reply per thread; the global limiter still applies; enabled is the kill switch. |
| Prompt injection through the widget | Same surface as any chat: content is a user message; the agent’s input/output guardrails apply unchanged; embed messages never carry a system role or tool results. |
| Identity forgery | HS256 with a per-key secret only your backend holds; alg pinned; exp ≤ 24 h, iat/nbf clock-skew checked; identity_required makes anonymous sessions impossible for that key. |
| PII | Identity fields land in end_users with PII tags; never in prompts or checkpoints; meta capped at 16 keys. Message content is logged at debug only. |
| Secret exposure | Identity secret encrypted (AES-GCM) at rest and decrypted only inside the session handler; the session-token key stays in memory; tests assert no secret appears in list/get responses. |
| Clickjacking of the console preview | The console preview iframe is same-origin only; the public bundle never renders console UI. |