All Work
VOICE AGENT / PERSONAL SELF-INITIATED PRODUCT

Layla Voice PWA.

A private, single-user PWA for starting a voice call with a personal Vapi assistant from a phone, without the private API token ever reaching browser JavaScript.

SecurityVoice AIPWA
Role
Author
Timeline
2026-09-01 to 09-09, 18 commits, in progress
Live URL
Personal tool, local-only, no public deployment

Local-only build, no public deployment

01 / THE REQUEST

Talk to the agent,not leak the key.

A personal Vapi voice assistant needed a phone-installable PWA to start calls from, without embedding the private Vapi API key in client-side JavaScript, where anyone with DevTools open could lift it.

02 / THE PROBLEM

The private keycan't just become public.

Vapi's /call/web endpoint rejects the private key outright, it expects a separately-managed public key. Provisioning a permanent public key is one option, but it is a second long-lived secret to rotate and scope. The alternative is minting short-lived, narrowly scoped credentials on demand, server-side, per call.

03 / THE CHALLENGE

A 60-second key,minted per call.

The server signs its own HS256 JWT using the private key as the HMAC secret, scoped to {allowedOrigins, allowedAssistantIds, allowTransientAssistant: false} and expiring in 60 seconds (src/vapi-proxy.mjs:6-12). It is used for exactly one outbound server-to-server request and discarded, never returned to the browser.

The upstream request body is rebuilt from server-pinned constants every time rather than merged with whatever the browser sends: assistantId and the caller identity come from config, not the request, and the browser's body is only inspected to flag suspicious override-looking keys in the logs (vapi-proxy.mjs:44-53,75-95). The response is filtered on the way back out too, down to five allowed fields, so nothing Vapi's API might echo beyond that reaches the client (vapi-proxy.mjs:42,55-64). On failure, only a generic error code crosses the boundary; the real upstream error stays server-side (vapi-proxy.mjs:131-141).

The call endpoint is double-gated: an HttpOnly, HMAC-signed session cookie checked with a constant-time comparison, and a separate per-session proxy token sent as a bearer header, also constant-time compared (session.mjs:46-104). Neither alone is sufficient.

The content security policy was not assumed, it was reverse-engineered against real failures. script-src 'unsafe-eval' is required because Daily's WebRTC bundle genuinely calls eval(); without it no call connects. worker-src 'self' blob:' is required because the mic-capture AudioWorklet loads from a blob URL; without it addModule() throws and was confirmed as the root cause of a live "Layla can't hear me" report (server.mjs:70-93).

04 / THE SOLUTION

What gotbuilt.

Per-call, 60-second JWT

The private key never leaves the server. A scope-restricted token is minted, used once, and discarded.

Server-pinned request body

Assistant ID and identity come from config, never from the browser's request. Responses are field-filtered on the way out.

Two-secret call gate

A session cookie and a separate proxy token, both constant-time compared, both required to place a call.

CSP debugged against real failures

Every policy exception is tied to a specific reproduced failure, not a blanket allowance.

05 / THE RESULT

Built,not yet shipped.

BUILT WITH

Node.js (http)VapiService WorkerWeb App Manifest

Integrating a voice agent, safely?

Next project

King Academy