Skip to main content

Security model

This page describes what each party in the Voxa system holds, what actually leaves your laptop, and where the trust model has real limits. Where hosted (zero-config) mode and self-hosted mode differ, both are called out.

Who holds what

PartyHolds
Laptop, hosted modeA per-machine pairing token and relay code (both 128-bit, generated locally). No Gemini or APNs keys.
Laptop, self-hosted modeYour own GEMINI_API_KEY, plus the same pairing token.
Phone (iOS app)The scanned pairing token, your billing account id, and (if signed in) a session JWT. All in the Keychain.
Voxa's cloud (hosted mode only)The Gemini API key, APNs push keys, the JWT signing secret, an admin token, and billing balances/ledgers.
Google (Gemini Live)The live voice/text stream, using whichever key is in play for your mode (yours in self-hosted, Voxa's in hosted).
AppleIssues the StoreKit purchase receipts and App Attest attestations that Voxa verifies against Apple's own roots.

What leaves your laptop

In hosted (zero-config) mode, the honest statement is: your voice audio, the live transcripts, and tool-call arguments and results all pass through Voxa's cloud in plaintext, after TLS has terminated there. This is not end-to-end encryption, and it is not a zero-knowledge design.

:::warning Not end-to-end encrypted In hosted mode, the "encrypted" in "encrypted relay" means TLS in transit. TLS terminates at Voxa's cloud, so that cloud process sees plaintext voice audio, transcript text, and tool-call arguments/results while a call is in progress. Voxa is not end-to-end encrypted between phone and laptop, and it is not zero-knowledge. If that's a dealbreaker for your use case, run self-hosted with your own Gemini key, and voice goes directly to Google instead. :::

What that plaintext exposure does and doesn't include:

  • The relay (/agent and /ws sockets) matches your phone to your laptop purely by the 128-bit pairing code and copies frames both ways. It does not parse or store audio or transcript content; the only thing it inspects in text frames is a literal "__peer" control marker.
  • The metered /live proxy is different: it hosts the actual Gemini Live session using the cloud's key, so it sees everything Gemini sees, meaning your mic audio, the injected recap/system prompt, and every tool call's name, arguments, and result (these are RPC'd back to the laptop for execution and the result is RPC'd back to the cloud).
  • Terminal execution, your files, and Claude Code itself never leave the laptop. The cloud can see a tool call's arguments and its result, but it does not run a shell and cannot execute anything itself.
  • Screenshots go only to your phone, relayed over the same /ws traffic. They are never sent to Gemini.
  • What the cloud logs: a session-log row per call with account id, start/end time, duration in seconds, and token counts. It does not log transcript content.

In self-hosted mode, none of this applies: your laptop talks to Google Gemini Live directly with your own GEMINI_API_KEY, and nothing voice-related passes through Voxa's cloud at all. (You can still choose to point your own deployment's relay/proxy at Voxa's cloud source code, but that's an opt-in choice, not the default.)

Pairing

Pairing is what lets a specific phone reach a specific laptop, and it's built from two locally generated secrets:

  • A 128-bit pairing code (secrets.token_hex(16)), used by the relay to match phone and laptop.
  • A 128-bit per-machine auth token, used by the laptop to gate its own local socket.

Both are generated on first run and persisted to ~/.voxa/ as separate files with 0600 permissions. The CLI prints them as a QR code and a plain URL, in the form {relay}/?code=<code>&token=<token>, so you scan once and you're paired.

On iOS, Pairing.parse forces the scheme to https at parse time regardless of what was scanned, and the WebSocket URL is always built as wss://.../ws. There's no path where a pairing URL downgrades to plaintext HTTP or ws://.

The laptop always dials outbound to the relay; it never opens an inbound port, so there's nothing on your laptop listening on the public internet.

After pairing, the two sides store the secrets differently:

  • Laptop: auth_token and relay_code as 0600 files under ~/.voxa/.
  • iPhone: the pairing token and billing account id in the Keychain, marked AfterFirstUnlockThisDeviceOnly, which keeps them out of encrypted iCloud backups.

:::warning Possession of the pairing URL is the authorization The relay matches phone to laptop purely by the pairing code; it does not separately verify the pairing token. Anyone who has the QR code or the pairing URL can drive your paired laptop. Treat the QR image the way you'd treat a password: don't screenshot it into a shared album, don't paste the URL into a chat. :::

Accounts and tokens

Anonymous accounts. The iOS app can mint a "d-" + UUID account entirely on-device (about 122 bits of randomness from the UUID) without ever talking to a server first. These are self-asserted: the balance, purchase, and live-call endpoints all accept a d- id with no bearer token. New-account creation is rate-limited, both per client IP (5 per hour by default) and globally (200 per hour by default), over a sliding window. That limiter is in-memory per server process, so it resets on restart and isn't shared across multiple instances, which is a real gap if the cloud ever runs more than one process.

Optional App Attest binding. Off by default. When turned on (VOXA_REQUIRE_ATTESTATION), an unattested d- account gets zero trial minutes and is refused on the live-call socket. When it runs, it's real cryptographic verification: parsing the CBOR attestation, verifying the X.509 chain up to Apple's App Attest root, checking the nonce and app id hash embedded in the certificate, and requiring a strictly increasing counter on every subsequent assertion to block replay. Any mismatch fails closed. On the client side, App Attest is best-effort and silently no-ops on the Simulator or older hardware, so it never blocks you from launching the app.

Signed-in accounts. A signed-in session is an HS256 JWT whose claims are exactly the user id, issued time, and expiry, nothing else. It's valid for one year, and there is no refresh-token flow and no server-side revocation list yet: a token is valid until it expires, full stop, even if the account is later deleted.

Push and ring routes. The /register, /unregister, /notify, and /call/decline routes are scoped by account id, not by a verified pairing or session token (the pairing token can't be checked server-side from these routes, so it's accepted but not enforced). In practice, this means the ~122-bit account id itself is the authorization for these routes: anyone who learns your account id could register a push token against it or trigger a ring/cancel. This is a deliberate trade-off for a system with no login requirement, not an oversight, but it's worth stating plainly.

Transport

Every socket that crosses the public internet uses TLS: the relay is wss://, the phone forces https/wss at parse time, and the default relay is https://api.voxa.space. On iOS, there are no App Transport Security exceptions in the project, so the platform default applies: TLS is required and arbitrary plaintext loads are disabled.

The one plaintext socket in the whole system is the laptop's own loopback connection, ws://127.0.0.1:<port>, which never leaves the machine and is gated by the local auth token (a mismatched token closes it immediately).

Billing

Purchases are verified, not trusted at face value:

  • StoreKit 2 receipts arrive as signed JWS. Voxa verifies the JWS signature against the leaf certificate, then does mandatory chain verification up to a bundled Apple root certificate. If that root is missing, or the chain doesn't anchor to it, verification fails closed and the purchase is rejected.
  • Sandbox purchases are rejected in production unless an operator explicitly sets VOXA_ALLOW_SANDBOX_PURCHASES, so a sandbox receipt can't be replayed against the live billing ledger by default.
  • Idempotency: each Apple transaction id is credited at most once, so a retried or resent receipt can't double-credit an account.
  • The free-call meter fails open, on purpose. If the metering system hiccups, a real call is still allowed to proceed rather than being blocked. This is the one place in the billing path that deliberately favors availability over strict enforcement; everywhere else (StoreKit verification, App Attest, billing-minute metering) fails closed.

Known limitations

Stated plainly, with mitigation or roadmap status where there is one:

  • Session tokens live for a year with no revocation. A leaked JWT stays valid until it expires; deleting the account removes the user record but does not invalidate an already-issued token. There's no revocation list in v1.
  • App Attest is off by default. Without it, protection against anonymous trial-account abuse is just the rate limiter, and that limiter is in-memory per process, so it resets on restart and doesn't coordinate across multiple server instances. Turning on VOXA_REQUIRE_ATTESTATION closes this gap for real iOS hardware, at the cost of also gating the Simulator and non-attested devices to zero trial minutes.
  • Pairing trust is possession-based. The relay matches on the pairing code alone; holding the QR or URL is enough to drive the paired laptop. There's no second factor. Keep the QR private.
  • Push/ring routes trust the account id alone, not a verified token, because the server can't check the pairing token from those routes. Security there rests on the account id being hard to guess (about 122 bits), not on an enforced credential check.
  • Not end-to-end encrypted. In hosted mode, Voxa's cloud terminates TLS and its processes see plaintext voice audio, transcript text, and tool-call arguments/results while a call is active. Self-hosting removes the cloud from the voice path entirely.
  • The free-call meter fails open by design, so over-quota enforcement on that gate is best-effort rather than a hard guarantee. Billing-minute metering and purchase verification, by contrast, fail closed.
  • The optional relay/proxy gating tokens default to empty. VOXA_PROXY_TOKEN (gates the /live proxy) and VOXA_RELAY_TOKEN (gates the relay sockets) are unset out of the box, so by default the pairing code is the only thing gating those sockets. Operators running their own deployment of the cloud service can set both.

Reporting a vulnerability

Email security@voxa.space, or open a private security advisory on the GitHub repository (voxa-code/voxa). Please don't file public issues for exploitable bugs.

See also

  • Architecture: the components and topologies this page assumes.
  • Self-hosting: how to run Voxa with your own Gemini key and take the cloud out of the voice path.
  • Configuration: every environment variable mentioned above, including the ones that are off or empty by default.