VOICEBOX_LOOPBACK_AUTH=1)VOICEBOX_LOOPBACK_AUTH=1 enables an opt-in bootstrap ticket and HttpOnly session cookie gate on server.mjs and vite.config.js (implementing Option A from 13-local-browser-authentication-options.md).
While the default loopback Origin check blocks Cross-Site WebSocket Hijacking (CSWSH) from external websites, enabling VOICEBOX_LOOPBACK_AUTH=1 additionally blocks unauthenticated local processes (such as other OS users or unprivileged containers sharing the loopback network) from accessing the web UI, REST APIs, /channel, or /live.
The gate is OFF by default, and the owner has ruled it stays that way for testing (voicebox-beads-k74h). The default is accepted, not silent:
VOICEBOX_LOOPBACK_AUTH is not 1 (unset or disabled), server.mjs prints a warning at startup naming the exposure and the flag remedy.Origin check distinguishes browser contexts but requires no credential. A page the operator merely visits can cause the browser to send requests that reach state-mutating routes — writing files into the active workspace and registering environments — with no credential, no prompt, and nothing visible in the page. The state-mutating surface includes a command-execution verb, so the exposure ceiling is higher than file writes. The response is not readable cross-origin, but the server has already acted.VOICEBOX_LOOPBACK_AUTH=1, an unauthenticated HTTP request is refused with 401 (loopback-unauthenticated) before any route answers (WebSocket upgrades are refused too, under their own names — §1 item 6). The exemptions are the wall’s own self-authorising checks (§1, “HTTP & API Wall”): GET /api/health remains 200 for supervisors and readiness checks (it exposes no file contents and no credential, though it does report the declared root and project name), and the bootstrap door, the ?bootstrap= page, and host-token requests pass on their own authority. This is pinned by tests/loopback-auth.test.mjs. On the Vite front, a healthy /api/health keeps the server indicator up even when /api/root returns 401; the page names the missing session and asks for a bootstrap ticket on that front’s origin rather than claiming the server is offline (tests/health-auth-browser.test.mjs).404, so nothing acts on that path. No executable reproduction is published here; the owner ruling records the impact at this conceptual level only.VOICEBOX_LOOPBACK_AUTH=1 outside local testing — the gate-off default exists for the raw command-line testing workflow, and the flag is one restart away.Enabling the gate is one restart away:
VOICEBOX_LOOPBACK_AUTH=1 npm start
VOICEBOX_LOOPBACK_AUTH=1 npm start
server.mjs generates a random session secret held solely in process memory. Restarting the server invalidates prior session cookies.bootstrap http://127.0.0.1:8787/?bootstrap=<64-hex-ticket>
The startup text then names the host the ticket must be opened on: a redemption’s Set-Cookie carries no Domain attribute, so the browser scopes the session to the HOST that redeemed it, and a cookie minted at 127.0.0.1:8787 is never sent to a page served from localhost:5173 (the symptom reported 2026-10-12). On the dev front (§2) the same ticket is opened on that host — http://localhost:5173/?bootstrap=<ticket>, which the loopback-bootstrap-proxy plugin forwards and whose Set-Cookie lands on :5173 — so the printed block says so instead of leaving the person to follow the 127.0.0.1 URL from a :5173 page, get a live cookie that page cannot present, and spend the only ticket. Issuance is unchanged: one ticket, single-use, on whichever host is opened first (voicebox-beads-t43w). The 127.0.0.1:5173 spelling is the same host case for a person who browses that address.
GET /?bootstrap=<ticket>) (voicebox-beads-fo6m):
vb_session=<secret>; HttpOnly; SameSite=Strict; Path=/, and answers HTTP 303 whose Location is the same route with the ticket removed (/, or /?room=… when the launch carried other parameters). The browser stores the cookie from the redirect and re-requests the plain route, so the ticket never stays in the address bar and the first refresh — the thing everyone does — is authenticated instead of re-presenting a consumed ticket.401 (bootstrap-ticket-refused) with the remedy in the body. Reusing one with a valid session — a bookmark, browser history, the back button — answers the same 303 to the plain route: an authenticated browser has no authorisation question left, and a refusal there reads as a broken server. Subsequent page reloads authenticate automatically via the vb_session cookie.vb_session cookie or x-voicebox-host-token header is refused before reaching any route with HTTP 401 (loopback-unauthenticated). When the request’s Host names the dev front (localhost:5173 / 127.0.0.1:5173 — the Vite hop preserves Host, so this survives the proxy) the refusal appends one clause naming that front, saying why it holds no cookie (a session is scoped to the host that redeemed the ticket), and giving the remedy: redeem a ticket on the host you browse, swapping 127.0.0.1:8787 for the host in the address bar. That is the shape a dev-front user sees when every /api call answers 401 while the page (served by Vite) stayed 200 (voicebox-beads-t43w). No ticket, host token or other secret appears in a refusal.lib/timing-safe.mjs (timingSafeStringEqual): equal lengths first, then crypto.timingSafeEqual, because the primitive throws on a length mismatch and a bad token must be a refusal rather than a crashed request. The vb_session cookie check here, the host token in lib/extensions.mjs (hostTokenOk), and both in-room session-token comparisons in server.mjs (extension authority and POST /api/root) now answer with that one implementation instead of four inline ones (voicebox-beads-sseh / GH #26). The gate still reveals the expected length, and no timing exploit is claimed for this local posture — this is hardening.GET /api/health (remains open for process supervisors and readiness checks; exposes no files or credentials).POST /api/bootstrap (validates x-voicebox-host-token to mint a fresh ticket).GET /?bootstrap=<ticket> (redeems the bootstrap ticket).POST /api/bootstrap):
curl -X POST http://127.0.0.1:8787/api/bootstrap \
-H "x-voicebox-host-token: $(cat ~/.config/voicebox/.host-token)"
url, naming this server’s origin, carrying one fresh single-use ticket. Redeeming it from the dev front means swapping the host in that URL (§2) — the fix for the reported symptom is guidance about the host, not a second credential (voicebox-beads-t43w)./channel & /live):
VOICEBOX_LOOPBACK_AUTH=1 is active, local same-origin WebSocket upgrades must present both a loopback Origin header and a valid vb_session cookie (browser/acts.ts sends the cookie automatically on same-origin upgrades).Origin: http://127.0.0.1:8787 without the cookie fall through to the remote pairing-bearer check and fail with executor-unauthenticated (/channel) or unauthenticated-call (/live). Paired remote peers authenticating via {"type":"hello","bearer":"vbx_..."} continue to work unchanged.vite.config.js)When running npm run dev (localhost:5173 fronting 127.0.0.1:8787), the loopback-bootstrap-proxy plugin in vite.config.js intercepts requests carrying ?bootstrap= and forwards them directly to server.mjs, returning the upstream status (including the redemption’s 303 and its relative Location) and the Set-Cookie header to the browser on the :5173 origin so subsequent proxied API and WebSocket requests carry vb_session. The redirect target is deliberately relative: the browser follows it back to the dev front it came in on — and stayed on the origin that now holds the cookie — instead of being handed to the 127.0.0.1:8787 API origin. That is why the startup text tells a dev-front user to open the same ticket on localhost:5173 (§1 item 2): it is the host in the URL that decides where the cookie lands, and the ticket is the same one, redeemed once, on whichever host is opened first. The /api, /live and /channel hops rewrite Origin to the API target but preserve Host, which is what lets a refusal name the front a request came in on (§1 item 4).
| Caller / Scenario | Gate Off (Default) | Gate On (VOICEBOX_LOOPBACK_AUTH=1) |
|---|---|---|
| External website in browser (CSWSH) | Blocked (Origin check) |
Blocked (Origin + SameSite=Strict cookie) |
Local script with forged Origin (no ticket/token) |
Allowed on loopback | Refused (401 / WS 1008) |
Local CLI with .host-token (x-voicebox-host-token) |
Allowed | Allowed |
Paired remote environment (vbx_... bearer) |
Allowed | Allowed |
| Browser launch workflow | Open http://localhost:8787 |
Open the printed ?bootstrap= URL on the host you browse (cookies are per-host: swap 127.0.0.1:8787 for localhost:5173 when you are on the dev front, §1 item 2); it redeems once and redirects to the plain route |
node --test tests/loopback-auth.test.mjs tests/server-bind-resilience.test.mjs