Security
What the Web SDK proxy enforces, and how to run it safely
Only the proxy sends x-api-key. Each request is checked in this order: route enabled, method, origin, authorize, rate limit, then the route's own body validation.
What the proxy enforces
| Check | Behaviour |
|---|---|
| Auth | authorize(request) returns { id } or null (401 unauthorized). A throw is treated as null and sent to onError. |
| Character allowlist | Ids not in characters get 403 character_not_allowed. questionSets works the same for quizzes. Both can be functions of the user. |
playerId | Always set to the authorize id, so per-player caps can't be spoofed. |
| Request bodies | Rebuilt from known, type-checked fields. The browser body is never forwarded as-is. |
| Sessions | Session ids are signed tokens bound to the user (nxs_... chat, nxq_... quiz). A foreign chat token starts a fresh conversation; a foreign history or quiz token gets 404 session_not_found. |
| Speech | Only lines a character actually said to this user can be voiced (speechToken). Anything else gets 403 speech_not_allowed. Disable with speechRepliesOnly: false. |
| Game context | Browser metadata and events are dropped unless allowClientContext: true. Use buildContext(user, turn) for trusted context; it is placed so browser items can't push it out. |
| Size limits | Message, speech, JSON, audio, characters per turn, metadata and events are capped. Bodies are streamed and cut at the limit, regardless of Content-Length. Wrong content type gets 415. |
| Rate limit | 30 requests a minute per user by default. Over the limit: 429 rate_limited with Retry-After. |
| One turn per conversation | A concurrent turn on the same conversation gets 409 operation_in_progress, Retry-After: 2. |
| Origin | Cross-site and sibling-subdomain requests get 403 origin_not_allowed unless listed in cors.origins. |
| Upstream errors | Server errors become 502 upstream_error; a bad or suspended key becomes 502 proxy_misconfigured. Details go to onError, which never receives the key. |
| Output filter | filterText(text, context) rewrites every line, spoken text and history before it reaches the browser. |
Defaults and error codes: Proxy Options.
Anonymous visitors
Without a login, give each visitor an id in a signed, httpOnly cookie set by your server. Never trust an id the browser picks.
import { createHmac, timingSafeEqual } from "node:crypto";
const COOKIE = "nx_visitor";
const sign = (id) => createHmac("sha256", process.env.SESSION_SECRET).update(id).digest("base64url");
function visitorFrom(cookieHeader) {
const match = new RegExp(`(?:^|;\\s*)${COOKIE}=([\\w-]+)\\.([\\w-]+)`).exec(cookieHeader ?? "");
if (!match) return null;
const [, id, signature] = match;
const expected = Buffer.from(sign(id));
const given = Buffer.from(signature);
return expected.length === given.length && timingSafeEqual(expected, given) ? id : null;
}
// On page load, if visitorFrom() is null, set:
// `${COOKIE}=${id}.${sign(id)}; Path=/; HttpOnly; SameSite=Lax`
const handler = createNeocortexHandler({
apiKey: process.env.NEOCORTEX_API_KEY,
characters: ["your-character-id"],
authorize: (request) => {
const id = visitorFrom(request.headers.get("cookie"));
return id ? { id } : null;
},
});Serverless hosts
Session and speech tokens are stateless HMACs, so any number of instances work without shared storage as long as they sign with the same key. The key is derived from apiKey; set secret to keep tokens valid across key rotations.
The built-in rate limiter and the one-turn lock are per instance. For a global rate limit, pass a RateLimiter backed by a shared store:
import { createNeocortexHandler, type RateLimiter } from "@neocortex-link/web/server";
const rateLimit: RateLimiter = {
async take(userId) {
const count = await store.increment(`nx-rate:${userId}`, { expireSeconds: 60 });
return count <= 30 ? { allowed: true, retryAfter: 0 } : { allowed: false, retryAfter: 60 };
},
};
const handler = createNeocortexHandler({ /* ... */ rateLimit });The rate limit counts every request, including one speech request per voiced line and history loads. Raise it for characters with long replies.
Prototyping without a server
import { NeocortexClient } from "@neocortex-link/web";
const client = new NeocortexClient({ apiKey: "sk_...", dangerouslyAllowBrowser: true });Local experiments only. Without the flag, an apiKey in a browser throws browser_api_key.