# Authenticating the local API OmniVoice's backend is **loopback-only and unauthenticated by default** — a script running on the same machine as `http://localhost:3900` needs no key, no PIN, no header. Everything on this page only matters once you reach the backend from **another device** (a phone on your LAN, a laptop over Tailscale, a client behind a reverse proxy). There are two independent gates, both **inert until you turn them on**, plus one env var that exempts trusted callers: | Gate | Turn on with | Guards | Applies to | |---|---|---|---| | **Share PIN** | the in-app Network share toggle | casual LAN-share guests, one session | non-loopback **HTTP** | | **API key** | `OMNIVOICE_API_KEY` env var on the backend | a durable remote credential | non-loopback **HTTP + WebSocket** | | **Trusted networks** | `OMNIVOICE_TRUSTED_NETWORKS` env var | *exempts* the two gates above | non-loopback **consumption** routes only | Loopback traffic (`127.0.0.1`, `::1`, `localhost`) is **never** gated — local tools keep working unchanged whichever gate is set. > OmniVoice separates **consumption** (TTS, dictation, voices) from > **administration** (`/system/*`, `/api/settings/*` — RCE-class). The PIN and > trusted networks are *consumption* credentials; the **admin surface is only > ever reached from loopback or with the API key** (see [Admin routes](#admin-routes-and-server-mode)). > Both gates can be active at once. The PIN and the API key are independent; when > both are set, each is checked on the paths it covers. --- ## Share PIN The PIN is the lightweight, in-app path: flip on **Network** sharing (footer **Local** pill → **Network**, or **Settings → Sharing & Remote Access**) and the app generates a fresh **6-digit PIN** for that session. It is regenerated every time you enable sharing and is **never written to disk**. See [docs/sharing.md](sharing.md) for the UI walkthrough. While a PIN is set, every **non-loopback HTTP request** to an API route must present it. Supply it any one of three ways: | Where | How | |---|---| | Header | `X-OmniVoice-Pin: ` | | Query param | `?pin=` | | Cookie | `ov_pin=` — the backend sets this automatically after the first valid PIN, so browser sessions only prove it once | ```bash # From another device on the LAN — with the PIN curl http://:3900/v1/audio/voices \ -H "X-OmniVoice-Pin: 123456" ``` A missing or wrong PIN returns: ``` HTTP/1.1 401 Unauthorized {"detail": "PIN required"} ``` Notes on the PIN gate (`NetworkAccessMiddleware`, `backend/main.py`): - It covers **HTTP only** — it does **not** gate WebSockets. The dictation WebSocket has its own guard (see below), and the PIN does **not** authorize it; use the API key or a trusted network for remote dictation. - The SPA shell (`/`, `/index.html`, `/favicon*`, `/assets/*`, `/health`) is always served un-PIN'd so the PIN-prompt UI can load. - It is a **consumption** credential: a valid PIN never unlocks the admin surface (it is 6 digits, brute-forceable). Admin needs loopback or the API key. - It is completely inert when no PIN is set (the default, and every Docker deploy). --- ## API key The API key is the durable credential for running the backend somewhere and driving it remotely — a GPU box on your tailnet, a Docker container, a reverse-proxied host. Set it on the **backend** process: ```bash # Generate a strong key and start the backend with it export OMNIVOICE_API_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(24))')" uv run uvicorn backend.main:app --host 0.0.0.0 --port 3900 # Docker: pass -e OMNIVOICE_API_KEY=… ``` While `OMNIVOICE_API_KEY` is set, every **non-loopback HTTP and WebSocket** request must present it (the SPA shell paths below are the only HTTP exception). Supply it any one of three ways: | Where | How | |---|---| | Header | `Authorization: Bearer ` — **preferred**; the one place a key isn't at risk of landing in a log | | Cookie | `ov_key=` — set automatically after the first authenticated HTTP request; the safer fallback for browser WebSockets | | Query param | `?api_key=` — last resort (browser WebSockets can't set headers). **A key in a URL leaks into proxy/access logs and browser history** — prefer the header or cookie | ```bash # Prefer an encrypted transport (Tailscale Serve / TLS) for a real key; plain # http:// on an untrusted network exposes the Bearer token on the wire. curl https://gpu-box:3900/v1/audio/speech \ -H "Authorization: Bearer $OMNIVOICE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"tts-1","voice":"alloy","input":"Hello from a keyed backend.","response_format":"wav"}' \ --output speech.wav ``` ```python # The OpenAI SDK sends the key as a Bearer token automatically from openai import OpenAI client = OpenAI( base_url="https://gpu-box:3900/v1", api_key="", # must match OMNIVOICE_API_KEY on the backend ) # (any string works ONLY when no key is set — the loopback default) audio = client.audio.speech.create( model="tts-1", voice="alloy", input="Hello from a keyed backend.", ) audio.stream_to_file("speech.wav") ``` A missing or wrong key returns: ``` HTTP/1.1 401 Unauthorized {"detail": "API key required"} ``` On a **WebSocket**, a missing or wrong key rejects the handshake with close code **1008** (policy violation) instead of a JSON body. Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`): - The key is compared in **constant time** and is **never logged**. - The SPA shell paths bypass the gate on **HTTP** so a remote UI can load and show what's wrong; WebSockets have no such exemption. - **Plain HTTP is sniffable** — a Bearer key over `http://` on a hostile network can be read off the wire. Use Tailscale (WireGuard) or TLS for anything beyond a fully trusted LAN. See [docs/remote-gpu.md](remote-gpu.md) for the full remote-backend setup. --- ## Dictation WebSocket The live-dictation stream at **`ws://:3900/ws/transcribe`** carries its own inline guard (`backend/api/routers/capture_ws.py`) *in addition to* the API-key middleware. A non-loopback client reaches it only if it is **either**: - on a [trusted network](#trusted-networks) (`is_local_host` passes), **or** - presenting the **API key** — as `Authorization: Bearer `, the `ov_key` cookie, or `?api_key=` (URL keys leak into logs — prefer the cookie). ``` ws://gpu-box:3900/ws/transcribe?api_key= ``` The **share PIN does not authorize dictation** — the PIN gate is HTTP-only, and the dictation guard checks only the API key (or trusted-network membership). A LAN guest who has only entered a PIN can use the HTTP API but **not** live dictation. When neither the API key nor a trusted network applies, the handshake is closed with code **1008** and reason `loopback origin required`. --- ## Trusted networks `OMNIVOICE_TRUSTED_NETWORKS` is a comma-separated list of **CIDR ranges** whose clients are treated as loopback-trusted by the **consumption** gates — so a reverse proxy or a trusted LAN/Tailnet can reach the API **without a PIN or key** (useful when a proxy strips the `Authorization` header). ```bash export OMNIVOICE_TRUSTED_NETWORKS="192.168.1.0/24,10.0.0.0/8" ``` What it exempts vs. what it does not: - **Exempts** (via `is_local_host`, `backend/api/dependencies.py`): the share PIN gate, the API-key gate, and the dictation WebSocket guard. Clients in a listed range need no PIN or key for these **consumption** routes. - **Never exempts admin.** `/system/*` and `/api/settings/*` are the RCE-class admin surface; trusted-network membership is a *consumption* exemption and does not reach them — even under `OMNIVOICE_SERVER_MODE=1`. See [Admin routes](#admin-routes-and-server-mode). Details: malformed CIDR entries are silently ignored (a bad entry never wedges the gate); IPv4-mapped IPv6 addresses (`::ffff:192.168.1.5`) from dual-stack proxies are unwrapped so they match IPv4 CIDRs; the value is read at request time, so in production **restart the backend** to apply a change. Default empty — no change to the strict loopback default. --- ## Admin routes and server mode Admin routes — `/system/*` (including `set-env`, **RCE-class**), `/api/settings/*`, engine install/uninstall, media tools, MCP bindings — sit on a stricter gate (`require_loopback`, `backend/api/dependencies.py`) than consumption. On the desktop build they are **true-loopback-only**: no PIN, key, or trusted network reaches them from another machine. In **server mode** (`OMNIVOICE_SERVER_MODE=1`, the Docker image) the loopback origin is unenforceable — NAT rewrites the source and even a `-p 127.0.0.1:3900:3900` mapping looks non-loopback — so the true-loopback requirement is dropped (issue #261, else the operator is 403'd out of their own `/system/*`). It is replaced by a **credential rule**, not removed: - **No credential configured** (no API key, no PIN) → admin is open. The bare Docker flow; exposure rests entirely on your port mapping / firewall. - **A credential is configured** → admin requires the **API key** (`Authorization: Bearer` / `?api_key` / `ov_key` cookie), or genuine loopback. The **6-digit share PIN does not gate admin** (it is brute-forceable), and trusted-network membership never does either. So a **PIN-only** server-mode deployment keeps admin loopback-only; remote admin requires the long API key. This is the fix for a real escalation (#1213): before it, server mode made the admin gate a no-op, so with an API key set *and* a trusted CIDR configured, a LAN client in that CIDR could `POST /system/set-env` — RCE-class — with **no credential at all**, because the API-key middleware waved it through as `is_local_host`. Now the admin gate is independent of the consumption exemptions. --- ## Status codes | Code | Meaning | What to do | |---|---|---| | **401** | Consumption auth failed — `{"detail": "PIN required"}` or `{"detail": "API key required"}`. | Supply the PIN / key (header, cookie, or query param above). A WebSocket surfaces this as close code **1008**. | | **403** | `{"detail": "loopback origin required"}` — you reached a **loopback-gated** route (admin: `/system/*`, `/api/settings/*`; or a `require_local` route from outside a trusted network) from a non-loopback origin. | A PIN won't help. Run the request from the box itself; for `require_local` routes add the caller to `OMNIVOICE_TRUSTED_NETWORKS`; for **admin** routes use `OMNIVOICE_SERVER_MODE=1` **and** present the **API key** (the PIN/trusted-network don't reach admin). | | **429** | **Not an auth failure.** The GPU pool is saturated (admission control) or a model download is rate-limited. Ships with `Retry-After` and `X-OmniVoice-Retryable: true`. | Back off for `Retry-After` seconds and retry the identical request. | --- ## See also - [docs/remote-gpu.md](remote-gpu.md) — end-to-end remote-backend setup over Tailscale, with the API key. - [docs/sharing.md](sharing.md) — the in-app LAN share + PIN flow. - [docs/agentic-voice.md](agentic-voice.md) — pointing OpenAI-compatible agent frameworks at OmniVoice. - [docs/mcp.md](mcp.md) — the MCP server for AI agents.