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:
{
"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 asplanbelow. - 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) ininclude_claimsreceives 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 (planis one oftrial | paid | free) so guidance stays stage-aware. They carry no security weight and never gate tools — unliketier, which drives usage caps.
Sign the token in your backend
Warning
Node.js
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
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.
<!-- 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.
- In the widget's Install tab, expand Advanced → Visitor sign-in setup.
- Switch Who signs visitor tokens? to My identity provider, using public keys.
- Paste your IdP's JWKS URL (e.g.
https://your-tenant.auth0.com/.well-known/jwks.json). - Select the allowed algorithms (RS256, ES256, etc.).
- Pass the IdP access token directly as
data-cmdk-token.
Note
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
issdoesn't match (e.g.https://acme.com). - Token audience — reject tokens whose
auddoesn'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.