Security
Two secrets are in play. Your API key is yours alone and lives on your server. A client secret is given to one client for one session and works for about a minute. Most of this page is about keeping them apart.
Keep API keys on your server
Section titled “Keep API keys on your server”- Put the key in an environment variable or your secret store. Never in a web page, a mobile app, a toy, a repository or a log.
- The examples read it only from
KHEELONA_API_KEYand never print it. - Give each service its own key, with only the scopes it needs. A route that only starts sessions needs the
sessions:writescope. - If a key might have leaked, rotate it now (see below).
Authenticate before you mint
Section titled “Authenticate before you mint”Your server turns a signed-in user into a client secret. If that route is open, anyone who can reach it can start sessions on your key and use up your limits. Check who is asking first. The example marks the place:
/** The mint route: create the session with your API key, hand the page only what it needs. */async function mintSession(env: BrowserEnv, req: IncomingMessage, res: ServerResponse, csp: string): Promise<void> { // Same-origin by design (no CORS headers). Only your own page may call this route: a request with no Origin, or // another one, never reaches Kheelona. JSON only, so a cross-site HTML form cannot spend your key. if (req.headers.origin !== env.allowedOrigin) { sendJson(res, csp, 403, { error: { code: 'origin_not_allowed' } }); return; } if (!req.headers['content-type']?.toLowerCase().startsWith('application/json')) { sendJson(res, csp, 415, { error: { code: 'unsupported_media_type' } }); return; } // Authenticate your user here: only signed-in users may start a session. // The request body is never read (this route takes no input), so its size and Content-Length don't matter. // One attempt only: to retry on 429/503, reuse one Idempotency-Key as examples/node-server does. const upstream = await fetch(`${env.apiUrl.replace(/\/+$/, '')}/v1/sessions`, { method: 'POST', headers: { Authorization: `Bearer ${env.apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': globalThis.crypto.randomUUID(), }, body: JSON.stringify({ voice_id: env.voiceId, languages: ['en', 'hi'], end_user_ref: 'demo-user', // replace with the signed-in user's opaque ID }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!upstream.ok) { const body = (await upstream.json().catch(() => ({}))) as { error?: { code?: unknown; request_id?: unknown } }; const code = plain(body.error?.code) ?? 'unknown_error'; const requestId = plain(body.error?.request_id); console.error(`error ${code} request_id ${requestId ?? '-'}`); const retryable = upstream.status === 429 || upstream.status === 503; const retryAfter = upstream.headers.get('retry-after'); sendJson(res, csp, retryable ? upstream.status : 502, { error: { code, ...(requestId ? { requestId } : {}) } }, retryable && retryAfter && /^\d+$/.test(retryAfter) ? { 'Retry-After': retryAfter } : {}); return; } const created = (await upstream.json()) as { session_id: string; client_secret: { value: string }; connect: { webrtc_url: string; websocket_url: string }; }; // Return only what the page needs: never the API key, and nothing else from the answer. sendJson(res, csp, 201, { sessionId: created.session_id, clientSecret: created.client_secret.value, endpoints: { webrtcUrl: created.connect.webrtc_url, websocketUrl: created.connect.websocket_url }, });}Also:
- Accept the call only from your own page: check the
Originheader, as the example does, and accept only JSON. - Send no CORS headers. The route should be same-origin with your page, or behind your own gateway.
- Return only the session ID, the client secret and the connection addresses. Never anything else from the answer.
- Pass your own opaque ID for the user as
end_user_ref.
Allowed origins
Section titled “Allowed origins”A browser connects to Kheelona with the client secret, and Kheelona checks the page’s origin against your allowed origins list. Open Allowed origins in the console and add every page that will use the SDK.
- An origin is a scheme, a host and a port, for example
https://app.example.com. It is matched exactly. - Use
https. Browsers give a page the microphone only onhttpspages and onlocalhost. The console refuses anhttporigin except forlocalhostwhile development access is on. - Use a public host name. IP addresses and one-word hosts work only while development access is on.
- A server client, such as a toy relay, sends no
Originand is not checked this way. It must not send one at all: see Server-to-server.
A refused origin gives origin_not_allowed, and the client secret is not used up.
Development access
Section titled “Development access”While you build, your page runs on http://localhost:<port>. A Kheelona admin turns on development access for your
account, and then any http://localhost address is accepted, listed or not. The console shows a banner while it is on.
Ask the Kheelona team to turn it off before you go live.
A pseudonymous end_user_ref
Section titled “A pseudonymous end_user_ref”end_user_ref ties a session record to a user of yours. Use an ID that only you can map to a person, such as u_8f3a2c:
at most 128 characters, from letters, digits and . _ : -. Never a name, an email address, a phone number or a child’s
details. It is kept with the session record. So are metadata values: no personal data there either.
Sign in to the console
Section titled “Sign in to the console”From the public launch, every console account sets up a second sign-in step at its first sign-in: a code from an authenticator app on the person’s phone. Each person does this for their own account. Before the public launch the second step may be optional.
Revoke and rotate keys
Section titled “Revoke and rotate keys”- Revoke a key in the console, under API keys. A revoked key stops working within 60 seconds.
- Rotate without a gap: create a new key, deploy it, check that sessions still start, then revoke the old one.
- Rotate right away if a key was shown in a log, a chat, a screenshot or a commit.
- Client secrets need no rotation. Each works once and expires after about 60 seconds.
Instructions are never kept readable
Section titled “Instructions are never kept readable”instructions is free text that shapes the voice for one session. It is sent encrypted to the voice and Kheelona never
stores or logs it in readable form, and it is processed in a US region, or in Mumbai for a voice set up to write replies in India (and in the US if the service in
India is unavailable; see the data and privacy
policy). Put in only what the voice needs: a first name and an age band rather than a full profile.
Instructions cannot switch off a voice’s safety rules.
A Content Security Policy for your page
Section titled “A Content Security Policy for your page”A strict policy keeps stray scripts out of the page that holds a live microphone. This is the one the example sends with
every response. It allows only your own origin and the origins of the connect URLs:
/** The strict policy the page runs under: this origin's files, and the Kheelona realtime origins to connect to. */export function contentSecurityPolicy(realtimeOrigins: string): string { return [ "default-src 'self'", "script-src 'self'", "style-src 'self'", `connect-src 'self' ${realtimeOrigins}`, "img-src 'self'", "media-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", ].join('; ');}connect-srchas your own origin and the Kheelona origins, for examplehttps://api.voice.kheelona.comandwss://api.voice.kheelona.com. The example reads them fromKHEELONA_REALTIME_ORIGINS.script-src 'self'works because the example hosts the SDK’s audio worklets itself and passesworkletBaseUrl. If you cannot host them, addblob:toscript-src. See the browser SDK guide.- No inline scripts and no inline styles. Put your code in files.