How do I authenticate end-users with JWT?

Switch your widget to signed-in mode and your backend signs a short-lived token for each logged-in visitor. CMD+K verifies the token, then passes the visitor's identity to every MCP tool call — so 'show me my orders' returns that user's orders, not everyone's. Without visitor identity, connected MCP tools run as a single shared account.

Why visitor identity matters

A CMD+K widget without visitor identity can still answer questions from a knowledge base. But as soon as you connect an MCP tool that touches user-owned data — orders, tickets, billing records, account settings — the tool has no idea which user to query. It either errors, or worse, returns data for a shared account.

With visitor identity enabled, CMD+K verifies the token, extracts the sub, and mints a fresh short-lived JWT specifically for each MCP server. Your API receives a call with a valid credential for that exact visitor.

Token format

A standard HS256 JWT signed with your widget's secret key. Claims:

json
{
  "sub":    "user_123",          // required — your stable user ID
  "email":  "alice@acme.com",   // recommended — shown in analytics
  "name":   "Alice",            // optional — shown in conversation view
  "scopes": ["read"],           // optional — ["read"] or ["read","write"]
  "tier":   "pro",              // optional — YOUR plan label; picks a per-tier usage cap
  "context": {                  // optional — verified defaults for tool arguments
    "project_id": "3",
    "role": "admin"
  },
  "plan":   "trial",            // optional — lifecycle stage: "trial" | "paid" | "free"
  "trialEndsAt": "2026-10-01T00:00:00Z", // optional — with plan:"trial"
  "onboarding": 40,             // optional — 0–100 percent, or a stage label
  "iat":    1750000000,         // issued-at (unix seconds)
  "exp":    1750000600          // expires-at — max 10 min from iat
}
  • sub — Stable, opaque user ID. Never starts with anon:.
  • scopes — Controls which MCP tools the visitor can invoke. Defaults to ["read"] when absent.
  • exp — Hard cap: 10 minutes from iat. Issue a fresh token on each page load.
  • tier — Free-form plan label from your product ("free", "pro", "enterprise"…). When you define per-tier usage caps under Monetize, a visitor's cap resolves as per-user override → tier → default. Not the same as plan below.
  • context — Flat object of verified defaults about the visitor's situation — project_id, org_id, role, anything your tools need as an argument. Up to 20 keys, key ≤ 40 chars, string values ≤ 200 chars; nested values are dropped. The assistant uses them as defaults for tool arguments instead of asking, curated-action templates can reference them as {{context.project_id}}, and a connector whose mint policy lists "context" (whole object) or "context.project_id" (one key) in include_claims receives them inside the minted backend JWT.
  • plan / trialEndsAt / onboarding — Optional lifecycle claims (top-level or nested under "lifecycle"). They tell the activation agent which stage the visitor is in (plan is one of trial | paid | free) so guidance stays stage-aware. They carry no security weight and never gate tools — unlike tier, which drives usage caps.

Sign the token in your backend

Warning

Keep the widget secret key server-side only. Never embed it in frontend JavaScript or commit it to version control.

Node.js

ts
import jwt from "jsonwebtoken"; // npm i jsonwebtoken

const token = jwt.sign(
  {
    sub:    user.id,
    email:  user.email,
    name:   user.fullName,
    scopes: user.isAdmin ? ["read", "write"] : ["read"],
    // tier:    user.planName,                    // optional — per-tier usage cap
    // context: { project_id: project.id, role: user.role }, // optional — tool-argument defaults
  },
  process.env.CMDK_WIDGET_SECRET!, // from Install tab → Widget secret key
  { algorithm: "HS256", expiresIn: "10m" },
);

// Inject into your HTML template:
// <script ... data-cmdk-token="${token}"></script>

Python

python
import jwt, time, os  # pip install pyjwt

token = jwt.encode(
    {
        "sub":    user.id,
        "email":  user.email,
        "name":   user.full_name,
        "scopes": ["read", "write"] if user.is_admin else ["read"],
        # "tier":    user.plan_name,                          # optional — per-tier usage cap
        # "context": {"project_id": project.id, "role": user.role},  # optional — tool-argument defaults
        "exp":    int(time.time()) + 60 * 10,
    },
    os.environ["CMDK_WIDGET_SECRET"],
    algorithm="HS256",
)

Pass the token to the widget

Set data-cmdk-token on the script tag. Your backend template injects the signed value server-side — the token is never fetched by the browser separately.

html
<!-- Server-rendered, e.g. in your Next.js layout or Rails erb -->
<script async
  src="https://commandplusk.com/embed/widget.js"
  data-cmdk-widget="<WIDGET_ID>"
  data-cmdk-token="<%= current_user_jwt %>">
</script>

Alternative: use your identity provider's tokens (JWKS)

If your frontend already holds a valid access token from Auth0, Clerk, Cognito, or another OIDC provider, you can skip re-signing. Configure the widget to verify tokens against your IdP's public keys instead.

  1. In the widget's Install tab, expand Advanced → Visitor sign-in setup.
  2. Switch Who signs visitor tokens? to My identity provider, using public keys.
  3. Paste your IdP's JWKS URL (e.g. https://your-tenant.auth0.com/.well-known/jwks.json).
  4. Select the allowed algorithms (RS256, ES256, etc.).
  5. Pass the IdP access token directly as data-cmdk-token.

Note

JWKS keys are cached for 5 minutes. New signing keys propagate to CMD+K within one cache window.

Scopes and write protection

The scopes claim in the visitor token controls which MCP tools they can call:

  • ["read"] — can call read-classified tools only (default when missing).
  • ["read","write"] — can call all tools including write-classified ones.

Separately, the widget setting Require approval for write actions adds a confirmation step before any write tool executes — regardless of scope. Use this for extra safety on destructive operations.

Advanced token validation options

Available under Install → Advanced → Visitor sign-in setup:

  • Token issuer — reject tokens whose iss doesn't match (e.g. https://acme.com).
  • Token audience — reject tokens whose aud doesn't match.
  • Maximum token age — independent of exp; tokens older than this are rejected. Default 600 s.

Next steps

FAQ

Why HS256 instead of RS256?
The widget secret lives on your backend server only — it never touches a browser. There's no need for asymmetric keys when both the signer and verifier are server-side. If your IdP already issues RS256/ES256 tokens you can use JWKS mode instead and skip re-signing entirely.
How short-lived should the token be?
10 minutes is our recommended maximum. Issue a fresh one on each page load from your backend session — don't cache it in localStorage.
What if my user is already signed in via Auth0, Clerk, or Cognito?
Use JWKS mode. Point the widget at your IdP's .well-known/jwks.json URL and pass the access token your frontend already has as data-cmdk-token. No re-signing needed.
What scopes should I send?
Send ["read"] for read-only visitors and ["read","write"] for users who should be able to trigger write actions via MCP tools. The widget also has a separate "Require approval for write actions" setting that forces a confirmation step regardless of scope.
Can I restrict which users can open the widget?
Yes — simply don't inject data-cmdk-token for users who shouldn't have access. Without a token, signed-in mode widgets refuse to start a session.
What is the anon: prefix restriction?
Token sub values starting with anon: are reserved by CMD+K for anonymous visitors. Signed JWTs with that prefix are rejected to prevent quota bypass.
What is the difference between tier and plan?
tier is your own plan label and only affects usage caps (per-user override → tier → default). plan (with trialEndsAt / onboarding) is a lifecycle hint for the activation agent — trial, paid or free — and never affects caps or tool access.
Does the context claim reach my MCP server?
Only when that connector's mint policy opts in: include_claims: ["context"] forwards the whole object inside the minted backend JWT; include_claims: ["context.project_id"] forwards a single key. Otherwise context stays inside CMD+K and is used only as tool-argument defaults.