Third-party chat authentication¶
Let people signed in to your application chat under their own identity. Your backend signs a short-lived user JWT with a per-chat app key; the browser or mobile app receives only that JWT. Superstep verifies it locally. No token-exchange endpoint is needed.
Set up an app key¶
- Save the workflow and enable Chat in its settings. Enable the access
option for third-party app users (
app) and choose the published slug. You can also allowanonymousorsigned_instudio users. - As the workflow owner or an admin, create an app key in Chat settings.
Give it a label and, for web apps, explicit allowed origins such as
https://shop.example.com(scheme, host and optional port; no path). - Copy
key_idand the one-timesecretinto your backend's secret manager. Listing keys shows metadata, never the secret. If lost, create another key. - In your backend, authenticate the person with your own login system, then
derive a stable user id and sign a token for that person. Return only the
user JWT from a private endpoint protected by your application's session,
with
Cache-Control: no-store. - Send the JWT using the Chat API. For browser requests use
credentials:'omit'; sockets send anauthframe before the message.
The owner/admin management endpoints are separate from the public Chat API:
GET /api/workflows/{wid}/chat/app-keys
POST /api/workflows/{wid}/chat/app-keys
DELETE /api/workflows/{wid}/chat/app-keys/{key_id}
Create body: {"label":"Shop backend","origins":["https://shop.example.com"]}.
The creation response's data.secret is shown once. Revoking in the UI
requires typing the item's displayed name; the DELETE API has no confirm
field. Creation and revocation are audited.
Token contract¶
The header is {"alg":"HS256","typ":"JWT","kid":"<key_id>"}. The
signature is HMAC-SHA256 over base64url(header).base64url(claims), using
the UTF-8 bytes of the secret exactly as shown. Do not base64-decode the
secret. Encode all JWT segments as base64url without = padding.
| Claim | Value |
|---|---|
iss |
The app's key_id, identical to header kid |
aud |
Exactly superstep-chat:<slug> |
sub |
Stable external user id, 1–128 characters, always a string |
name |
Optional display name, at most 80 characters |
iat |
Issued-at Unix seconds, numeric |
exp |
Expiry Unix seconds; later than iat, no more than 3,600 seconds after it |
nbf |
Optional not-before Unix seconds |
jti |
Optional identifier; there is no per-token denylist in this version |
meta |
Optional JSON object, at most 1,024 bytes when compact UTF-8 encoded; untrusted workflow input |
The examples issue 10-minute tokens. Keep clocks synchronized: verification
allows 60 seconds of skew for iat, exp and nbf. Only HS256 is accepted;
none, other algorithms, unknown keys, wrong audiences and bad signatures
are rejected. The entire token is at most 8,192 characters. Optional metadata
must fit both its own limit and that token limit.
Mint on your server¶
These standard-library examples are standalone helpers. The SDK source also
provides mintChatToken from @superstep/sdk/server and Python mint_chat_token;
use a compatible approved package version or the built checkout. Call a helper
on your server using a key/secret from secret storage and sub/name from your
authenticated application user. Never
accept an arbitrary user id supplied by the browser as the identity to sign.
Each helper returns a token; your private HTTP handler returns it to that
user. Keep secrets and returned tokens out of logs.
import { createHmac } from 'node:crypto'
// Server only. Read keyId/secret from your secret manager, and userId/name
// from the authenticated user in your own application.
export function mintChatToken({ keyId, secret, slug, userId, name = '' }) {
const now = Math.floor(Date.now() / 1000)
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url')
const header = encode({ alg: 'HS256', typ: 'JWT', kid: keyId })
const claims = encode({ iss: keyId, aud: `superstep-chat:${slug}`,
sub: userId, name, iat: now, exp: now + 600 })
const unsigned = `${header}.${claims}`
const signature = createHmac('sha256', secret).update(unsigned).digest('base64url')
return `${unsigned}.${signature}`
}
import base64
import hashlib
import hmac
import json
import time
# Server only; user_id/name come from your authenticated application user.
def mint_chat_token(*, key_id, secret, slug, user_id, name=""):
def b64(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def encode(value):
return b64(json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
now = int(time.time())
header = encode({"alg": "HS256", "typ": "JWT", "kid": key_id})
claims = encode({"iss": key_id, "aud": f"superstep-chat:{slug}",
"sub": user_id, "name": name, "iat": now, "exp": now + 600})
unsigned = f"{header}.{claims}"
signature = hmac.new(secret.encode("utf-8"), unsigned.encode("ascii"), hashlib.sha256).digest()
return f"{unsigned}.{b64(signature)}"
package chatmint
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"time"
)
// Server only; userID/name come from your authenticated application user.
func MintChatToken(keyID, secret, slug, userID, name string) (string, error) {
encode := func(value any) (string, error) {
data, err := json.Marshal(value)
return base64.RawURLEncoding.EncodeToString(data), err
}
now := time.Now().Unix()
header, err := encode(map[string]any{"alg": "HS256", "typ": "JWT", "kid": keyID})
if err != nil {
return "", err
}
claims, err := encode(map[string]any{"iss": keyID, "aud": "superstep-chat:" + slug,
"sub": userID, "name": name, "iat": now, "exp": now + 600})
if err != nil {
return "", err
}
unsigned := header + "." + claims
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(unsigned))
return unsigned + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
<?php
// Server only; $userId/$name come from your authenticated application user.
function mintChatToken($keyId, $secret, $slug, $userId, $name = '') {
$b64 = function ($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
};
$encode = function ($value) use ($b64) {
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new RuntimeException('Cannot encode chat token');
}
return $b64($json);
};
$now = time();
$header = $encode(['alg' => 'HS256', 'typ' => 'JWT', 'kid' => $keyId]);
$claims = $encode(['iss' => $keyId, 'aud' => 'superstep-chat:' . $slug,
'sub' => $userId, 'name' => $name, 'iat' => $now, 'exp' => $now + 600]);
$unsigned = $header . '.' . $claims;
return $unsigned . '.' . $b64(hash_hmac('sha256', $unsigned, $secret, true));
}
For example, the Node helper's arguments are
{keyId, secret, slug:'support', userId:currentUser.id, name:currentUser.name}.
Keep ids stable across devices and name changes. All keys of one chat share
the same external-user namespace: the same sub has the same history through
either rotation key. Include a tenant prefix in sub if your application
needs to separate otherwise identical customer ids.
Transport, origins and refresh¶
HTTP sends Authorization: Bearer <jwt>. For WebSocket v1, wait for ready,
then send {"type":"auth","token":"<jwt>"} before one message or
answer. ?token= is a compatibility fallback; prefer the frame because
URLs may reach proxy logs. Never put the app secret in a URL or client.
For cross-origin browser HTTP, the preflight must request Authorization; the actual request's signed token and origin are checked against the key. CORS never allows credentials. Cross-origin sockets also need a valid app token and an allowed Origin; the legacy socket stays same-origin.
An empty key origins list allows any origin, including a native/server
client with no Origin. A nonempty list requires a matching Origin and
rejects a request that has none. Use explicit origins for web-only keys.
These origins govern direct API/socket callers; the separate chat
embed_origins setting governs which sites may frame /c/{slug}.
Fetch a fresh JWT before its expiry or when CHAT_TOKEN_EXPIRED is returned,
using your private authenticated endpoint. Retry authentication once with
the fresh token; avoid an unbounded retry loop. The socket verifies the
token before its operation and does not cut a running stream when it expires.
A new connection or HTTP request verifies again. After an interrupted stream,
recover history before sending the message again.
The widget API supports identify through a checked parent/frame
bridge. Its app-key origin is the Studio iframe origin; embed_origins separately
allows the parent website. Direct React toolkit and headless SDK
clients instead allow their own application origin.
Workflow identity and history¶
Declare a state field named chat with type object in the workflow to
retain the injected input. A new app-user message provides:
For example {{state.chat.user.name}} reads the display name. Both name and
metadata are untrusted data: do not turn them into system instructions or
authorization decisions. Runs still use the workflow owner's credentials.
Human answers resume the existing run; they do not create a new message input.
History, files and ratings are scoped by (chat, external user id), not by
the device or active key id. Other app users, anonymous visitors and studio
users cannot access that history through public session endpoints. A valid
app token takes priority over an anonymous cookie; it does not convert or
merge anonymous history. Changing sub changes the user's history scope.
Rotate and revoke¶
Up to two active keys per chat allow rotation without a gap:
- Create a second key with the intended origins; store its secret securely.
- Deploy your token issuer to sign using its new
kidand secret. - Let clients refresh; both active keys work for the same user history.
- Revoke the old key in Chat settings. Revoke immediately on a suspected leak.
Keys are checked on every verification without a cache; revocation rejects
the next verification. An already authenticated stream is not reauthenticated
mid-run. Disable Chat or remove app access to reject all app-user operations.
There is no individual-token revoke endpoint; use short lifetimes, or revoke
the key when necessary.
Errors and limits¶
| HTTP | Code | Action |
|---|---|---|
| 401 | CHAT_TOKEN_REQUIRED |
Supply an app token for the requested access/origin |
| 401 | CHAT_TOKEN_INVALID |
Check signing configuration, audience, identity and clock; do not blindly retry |
| 401 | CHAT_TOKEN_EXPIRED |
Refresh using your authenticated backend |
| 401 | CHAT_APP_KEY_REVOKED |
Switch to an active key; investigate unexpected revocation |
| 403 | CHAT_ORIGIN_FORBIDDEN |
Correct the allowed origins or caller origin |
| 404 | CHAT_NOT_PUBLISHED |
Chat disabled, unpublished or unavailable |
| 404 | CHAT_SESSION_NOT_FOUND |
Unknown session or it belongs to another identity |
| 429 | RATE_LIMITED |
Back off using Retry-After or socket error.details.retry_after |
Cross-origin browsers can read an expired/revoked error only after signature and origin validation. Other rejected credentials/origins can appear as a CORS network failure; inspect server-side status/code without logging the JWT.
Per-IP read/message limits and the chat daily cap remain. The external-user
message limit is STUDIO_PUBLIC_CHAT_RATE_USER, defaulting to the IP message
limit (30 per 60 seconds by default). Uploads consume this quota; failed token
verifications count against the IP message limit. See
Configuration and Error codes.
Security checklist¶
- Keep app secrets in backend secret storage; never ship them, PATs or workflow tokens in browser/mobile bundles, screenshots, URLs or logs.
- Protect your mint endpoint with your application's authentication and
CSRF controls; derive identity server-side and return
Cache-Control: no-store. - Use HTTPS/WSS, short lifetimes and explicit web origins. Hold user JWTs in memory where practical; clear them on logout and avoid shared caches.
- Rotate keys, audit unexpected use, and revoke a leaked secret promptly. A leaked key can impersonate any user of that chat until revoked.
- Treat
name/metaas untrusted data and apply the chat's spending limits. - Use stable, tenant-scoped user ids; changing keys alone does not split history.
Continue with the React toolkit or Widget API. The widget guide explains why its app key allows the Studio iframe origin, separately from the parent embed allowlist.