Auth

Identity tokens.

A short-lived HS256 JWT your server signs with the Embed Secret. It tells Onpilot which agent to load and who the user is. The browser never has the secret.

How it works

  1. Your server calls onpilot.signIdentityToken({ copilotId, user, expiresIn })
  2. The SDK signs the JWT locally — no network call
  3. You pass the JWT to the widget via identityToken (or identityTokenProvider for long-lived sessions)
  4. The widget exchanges the JWT for a chat session

JWT

Claim shape

json
{
  "iss": "<tenantId>",
  "sub": "<userId>",
  "copilot_id": "<copilotId>",
  "role": "admin",
  "name": "Optional Name",
  "email": "optional@example.com",
  "iat": 1730131200,
  "exp": 1730134800
}
ClaimRequiredNotes
issyesTenant ID. SDK sets from your client.
subyesYour user's stable identifier.
copilot_idyesThe agent the user should load.
roleno"admin" or "user". Defaults to "user".
namenoDisplay name. Surfaces in greetings.
emailnoUseful for tools that need it.
iat / expyesStandard JWT timestamps.

Signed HS256 with your Embed Secret (per-tenant). Default expiry is 24h server-side; we recommend 1h for customer-facing flows.

Pattern

Static vs provider

Two ways to pass the token to the widget. Static is simpler; provider keeps long-lived sessions alive.

Static

tsx
<CopilotBubble identityToken={token} />
Footgun. If you sign with expiresIn: "1h" and the user keeps the page open for 90 minutes, the chat silently breaks at minute 60. Either bump the expiry or use the provider pattern below.

Provider — recommended for SPAs

tsx
<CopilotBubble
  identityTokenProvider={async () => {
    const r = await fetch("/api/onpilot/token", {
      credentials: "include",
    });
    const { identity_token } = await r.json();
    return identity_token;
  }}
/>

The SDK calls the provider on mount and again whenever the current token approaches expiry — refresh window is 80% of remaining TTL with a 30-second floor and 60-second ceiling.

Token endpoint (Next.js Route Handler)

ts
// app/api/onpilot/token/route.ts
import { NextResponse } from "next/server";
import { signOnPilotIdentity } from "@/lib/onpilot";
import { getCurrentUser } from "@/lib/session";

export async function GET() {
  const user = await getCurrentUser();
  if (!user) return new Response("unauthorized", { status: 401 });
  const identity_token = signOnPilotIdentity(user);
  return NextResponse.json({ identity_token });
}

Operations

Rotating the embed secret

Settings → Profile → Embed Secret → Rotate. Rotation is immediate. Every in-flight token signed with the old secret stops verifying as soon as you confirm.

If you use identityTokenProvider, rotation recovers automatically — the next token your endpoint mints uses the new secret. If you use static identityToken, in-flight users will see the next message fail; a page refresh server-renders a fresh token.

Errors

What can go wrong

The SDK fires onError with one of these codes when token resolution fails. Iframe runtime errors don't surface here — they're handled inside the chat.

CodeMeaning
RESOLVE_ERRORJWT rejected by the resolve endpoint — likely expired, signed with the wrong secret, or iss doesn't match a known tenant.
TOKEN_FETCH_ERRORYour identityTokenProvider threw or returned non-2xx. Check the endpoint, auth, and CORS.

Security

Allowed domains

Restrict where the widget can mount. Set Publish → Allowed Domains on each agent. The resolve endpoint enforces the allow-list — if the embedding origin isn't on it, the chat refuses to load.