Dev-Mode Silent Pairing
T3 Code contributor DX — auto-authenticate the web UI on localhost dev servers without exposing the WebSocket/HTTP API to drive-by attacks. Fully invisible to humans and agents. v3, 2026-07-13.
loopback-browser policy + loopback devUrl (the old "not remote-reachable" wording admitted desktop dev, and --dev-url was an unvalidated auth root); minted sessions carry administrative scopes (decision: match today's startup pairing so no settings surface degrades); the startup flow change is required, not optional (otherwise an unconsumed admin token is left live in the opened URL); the endpoint is always mounted and returns explicit errors (the SPA/302 wildcard fallback makes "unmounted → 404" false); and the loopback Host check needs a new parser (isLoopbackHostname handles no ports; reusing it from auth/http.ts would also create a module cycle). Size estimate revised up.
1. Problem & goals
The dev startup pairing credential is one-time and 5-minute TTL (PairingGrantStore.ts → DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES). Working on multiple T3 Code changes in parallel means every fresh browser context — a new worktree's auto-opened tab, incognito, and especially an agent's Playwright/headless profile — needs a fresh /pair#token=… URL scraped from interleaved dev-runner stdout. Agents routinely give up on browser verification because of this.
- Goal: a fresh browser context that loads the dev web app on localhost is authenticated automatically, with zero setup, zero visible tokens, zero log-scraping. Humans and agents never think about pairing in dev.
- Goal: no weakening of the auth model — a malicious webpage must still be unable to reach the WebSocket or authenticated HTTP API, which can spawn full-access coding agents (connect = RCE). Note
/api/orchestration/dispatchis reachable with standard scopes over plain HTTP, so the WS upgrade is not the only surface to protect. - Non-goal: changing prod, desktop, remote-access, or hosted-app pairing. The
/pairscreen remains as fallback and keeps precedence when an explicit token is present. - Non-goal: defending against malicious local processes — they can already read
~/.t3/dev/secretsdirectly.
2. Threat model — why not just disable auth
Loopback binding does not protect a dev server from the browser you are using. Any webpage can open ws://localhost:13773 — WebSocket handshakes are exempt from CORS — and plain fetch() requests to localhost also send fine cross-origin (CORS gates reading, not sending). The session cookie (+ WS ticket) requirement is currently the only barrier between an ad iframe and a full-access provider session. Drive-by localhost attacks are a well-worn class (Selenium, Jupyter, and Ollama all grew token auth for this reason). So "pairing off in dev" is off the table; instead we make pairing automatic for legitimate same-origin clients.
Review finding (Codex, confirmed): the Vite dev server must be treated as part of the attack surface, not as infrastructure. Its bind host comes from the ambient HOST env var (apps/web/vite.config.ts), independent of the backend's T3CODE_HOST that drives the auth policy — so HOST=0.0.0.0 bun run dev exposes Vite on the LAN while the backend stays loopback and policy stays loopback-browser. Requests arriving through the /api proxy are same-origin for the requesting browser (no CORS), and changeOrigin: true rewrites the Host header to loopback. Any guard that only inspects CORS-readability or the Host header is defeated by this path. The design below therefore gates on the Origin request header, which browsers attach unforgeably and the proxy forwards untouched.
3. Auth boundary: Origin as primary gate, CORS as backstop
Dev-only endpoint that mints a normal one-time pairing credential. Three layers, outermost first:
- Eligibility (mount-time facts, checked per-request): the endpoint only does work when the server is a local web-dev server:
mode === "web", auth policy exactlyloopback-browser,devUrlset anddevUrl.hostnameloopback. This excludes prod (nodevUrl), desktop dev (desktop-managed-localpolicy — its renderer origins are CORS-allowlisted, so the old "not remote-reachable" guard would have leaked the response to Electron contexts), LAN-bound dev (remote-reachable), and a hostile--dev-url(which today becomes a credentialed CORS origin with no hostname validation — this design must not turn it into an authentication root). - Request authentication (per-request, primary): non-simple
POST(JSON body → preflighted) requiringOriginto exactly equaldevUrl.origin, and the parsed requestHostto be loopback. Browsers attachOriginto all POSTs, including same-origin ones, and cannot forge it; it survives the Vite proxy unmodified. The Host check stays as anti-rebinding defense for non-proxied direct requests. - Response opacity (backstop): the existing
browserApiCorsLayerallowlists onlydevUrl.origin+ desktop renderer origins, so even a response that slipped the above would be unreadable cross-origin. No longer load-bearing on its own — the proxy path bypasses it — but it keeps the direct-fetch drive-by dead even if a guard regresses.
Scope decision (Theo, 2026-07-13): minted sessions carry AuthAdministrativeScopes — the same as today's startup pairing credential — under a dedicated subject (e.g. dev-auto-bootstrap, excluded from the Settings pairing-link listing like the existing internal bootstrap subject). Standard scopes would silently degrade Settings → Connections (needs access:write) and diverge from what contributors get today. The endpoint only exists on loopback web-dev servers, where the alternative credential with identical power is one ps/log-scrape away.
4. Complete flow
Sequence in words:
http://localhost:5733. Root-route auth gate (routes/__root.tsx) resolves session state → unauthenticated. If the URL carries an explicit pairing token (/pair#token=… or hosted-pairing params), the silent path is skipped entirely — manual and remote pairing keep precedence.POST /api/auth/dev-pairing-token (JSON body, so it is non-simple and preflighted) through the existing Vite /api proxy. Attempted only when plausible: import.meta.env.DEV, not isHostedStaticApp(), not a desktop-bridge context.dev-auto-bootstrap.exchangeBootstrapCredential in apps/web/src/environments/primary/auth.ts:231) — the same client function the /pair screen uses. Session cookie set; grant consumed./pair screen. No error toast — silence is the feature.5. Server changes
New contract endpoint: POST /api/auth/dev-pairing-token
Auth routes are a typed EnvironmentHttpApi group (packages/contracts/src/environmentHttp.ts:380) with one required handler chain in apps/server/src/auth/http.ts:200 — so this is a declared contract endpoint (success + error schemas in contracts), not an ad-hoc raw route. The handler is always mounted; ineligibility is an explicit typed 404-equivalent response. Relying on conditional non-registration is wrong in this codebase: the wildcard GET route 302-redirects to Vite in dev and serves the SPA index.html as fallback in prod (apps/server/src/http.ts:222–290), so an absent route would produce redirect loops or a 200 HTML page instead of a 404.
Eligibility (any failure → typed not-available error, mapped to 404):
config.mode === "web"
config.devUrl !== undefined
isLoopbackHost(config.devUrl.hostname) // --dev-url must not become an auth root
descriptor.policy === "loopback-browser" // excludes desktop dev + remote-reachable
Request authentication (failure → 403 + server log):
request method POST, JSON content-type // non-simple → preflight, Origin always present
request.headers.origin === config.devUrl.origin // exact match; primary gate, survives Vite proxy
parseRequestHost(request.headers.host) is loopback // anti-rebinding for direct (non-proxied) requests
Then:
mint one-time credential — AuthAdministrativeScopes, subject "dev-auto-bootstrap",
default 5-min TTL, label "dev-auto"
Respond:
{ credential, expiresAt } (Cache-Control: no-store)
- Host parsing needs a new helper.
isLoopbackHostname()(http.ts:66) matches only exactlocalhost/127.0.0.1/::1and does not strip ports — a rawHost: localhost:13773would fail, making the feature dead on arrival. Andauth/http.tscannot import it anyway:http.tsalready imports fromauth/http.ts, so that would be a module cycle. Extract one canonicalparseRequestHost+ loopback predicate into a dependency-neutral module (e.g. alongsidestartupAccess.ts'sisLoopbackHost, which already accepts127.*), and use it here; test raw Host values with ports, brackets, and malformed input. - Scopes/subject: mint via a dedicated
EnvironmentAuthmethod (sibling ofissueStartupPairingCredential) so the admin-scope + internal-subject combination is explicit and the subject is excluded from the Settings pairing-link listing likeINTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECTis today. - Flooding: every mint persists a pairing-link row and there is no pruning path (
AuthPairingLinks.tsfilters expired rows from listings but never deletes). The Origin-checked POST means unauthorized pages can no longer trigger the write at all (preflight fails before the request executes). Optional hardening, not required for v1: prune expired one-time rows on issuance.
CORS — no change, now backstop only
browserApiCorsLayer (apps/server/src/http.ts:49) already allowlists only config.devUrl.origin + the two desktop renderer origins, with credentials. The new route sits behind it. Keep the test asserting the response never carries ACAO: * — but note the desktop renderer origins being allowlisted is exactly why CORS opacity alone was insufficient as a guard (§3, eligibility check excludes desktop by policy instead).
6. Startup flow change (required)
Upgraded from "optional follow-up" after review. Today every non-desktop browser startup mints an administrative one-time token and opens /pair#token=… (serverRuntimeStartup.ts:252, resolveStartupBrowserTarget). With silent pairing, the gate authenticates before the /pair route consumes that token, and /pair then redirects away (pair.tsx:9) — leaving an unconsumed admin credential live for 5 minutes in the opened URL, terminal scrollback, and browser history. Two coordinated changes:
- Server: when the silent-pairing eligibility conditions hold (web dev mode, loopback,
loopback-browserpolicy),resolveStartupBrowserTargetopens the plain dev URL and skips minting the startup credential. Headless output, prod, and remote flows keep today's behavior. - Web: the gate skips the silent path whenever an explicit pairing token is present in the URL (covers manual
/pair#tokenlinks, remote pairing, hosted pairing), so explicit credentials are always consumed and never preempted.
7. Web changes
In apps/web/src/environments/primary/auth.ts and the root-route gate. The auth module already has a bootstrap state machine — bootstrapPromise dedup, cached auth state, and reset points used by backend swaps and tests (auth.ts:151, 510, 535). The silent attempt is modeled inside that state machine, not as a separate module-level latch: a standalone latch would survive reauthenticatePrimaryEnvironment and test resets, so one early 404 would disable the feature until a full page reload.
- Add
fetchDevPairingCredential()next to the other primary-environment requests: POST the endpoint, treat any non-200 / network error as "not available" and return null. Never surface an error. - In the bootstrap flow: when session state resolves unauthenticated and the attempt is warranted, try fetch →
exchangeBootstrapCredential→ re-resolve, before yielding thepairing-requiredstate. Attempt-once per bootstrap cycle, reset alongside the existing bootstrap fields (so backend restarts / HMR / reauth get a fresh attempt). - When to attempt:
import.meta.env.DEV(dead-code-eliminated from prod bundles), notisHostedStaticApp(), not a desktop-bridge context (don't rely on gate ordering to keep Electron off this path — its origins are CORS-allowlisted), and no explicit pairing token in the URL (§6). - Concurrent-tab race: two fresh tabs may both mint; each gets its own one-time token, both exchanges succeed independently. No coordination needed.
- The
/pairroute and surface are untouched.
8. Attack analysis
| Attack | Path | Outcome |
|---|---|---|
| blockedDrive-by mint | evil.example POSTs to the endpoint on localhost:13773 |
Non-simple POST → preflight fails (origin not allowlisted), request body never executes server-side (also kills row-flooding). Even a simple-request variant would die on the Origin check, and the response would be CORS-opaque. |
| blockedDNS rebinding | Attacker origin's DNS re-pointed at 127.0.0.1; fetch is now "same-origin" so CORS doesn't apply |
Origin: https://evil.example ≠ dev origin → 403. Host check independently rejects (Host: evil.example not loopback). Logged. |
| blockedVia Vite proxy | LAN device (when HOST=0.0.0.0 exposes Vite) or rebound page hits :5733/api/…; proxy rewrites Host to loopback and CORS never applies (same-origin for the requester) |
This defeated v2's guards entirely. Now: the browser-attached Origin header survives the proxy unmodified and doesn't match devUrl.origin → 403. Non-browser LAN clients can forge Origin — but they could equally hit the backend directly if it were exposed; the endpoint's eligibility already requires the backend itself to be loopback-bound, so forged-Origin attacks require code execution on the machine, which is out of scope. Pin Vite server.allowedHosts as defense in depth. |
blockedHostile --dev-url |
Server started with --dev-url https://some-site.example, which becomes a credentialed CORS-allowlisted origin |
New in v3: eligibility requires devUrl.hostname loopback → endpoint returns not-available. devUrl never becomes an authentication root. |
| blockedDesktop dev leak | Electron renderer origins (t3code://app, t3code-dev://app) are CORS-allowlisted and desktop dev sets devUrl |
New in v3: policy for loopback desktop is desktop-managed-local, and eligibility requires exactly loopback-browser + mode === "web" → not available in desktop dev. Desktop bootstrap failures can't be silently masked by this fallback. |
| blockedDirect WS connect | new WebSocket('ws://localhost:13773/…') from any page |
Unchanged: upgrade requires session cookie (SameSite=Lax withholds it cross-site) or a WS ticket the page can't obtain. |
| blockedLAN exposure of backend | Backend started with --host 0.0.0.0; LAN device hits the endpoint directly |
Policy is remote-reachable → not available. Normal pairing required, as today. |
| out of scopeMalicious local process | curl -X POST -H 'Origin: http://localhost:5733' localhost:13773/… |
Succeeds — local processes can forge every header. Accepted: they can already read ~/.t3/dev/secrets and the SQLite session store directly. No privilege gained. |
| unchangedProd / desktop / remote | Any | Endpoint answers not-available (explicit handler — no SPA-fallback 200s, no redirect loops). All existing flows byte-identical except dev startup no longer mints an unused admin token (§6). |
9. Parallel dev instances
This composes with the existing multi-instance setup (T3CODE_DEV_INSTANCE=<name> bun run dev, ports hashed from the name):
- Each dev server mints its own credentials — no cross-instance coordination.
- Web-dev instances that share a
T3CODE_HOME(the default~/.t3) share~/.t3/dev: same session DB, same signing secret. Thet3_sessioncookie is port-independent but host-scoped: sessions are shared across instances only when they use the sameT3CODE_HOMEand the same hostname —localhostand127.0.0.1do not share cookies, and two isolated homes on the same hostname will overwrite each other's cookie with mutually unverifiable tokens (the silent path then just re-pairs, which masks the collision harmlessly in dev). - Agents get the full win regardless of cookie sharing: headless profiles start cookie-less, silently pair on first
page.goto, and need zero token plumbing.
10. Test plan
| Area | Test |
|---|---|
| Server endpoint (with the existing server HTTP auth coverage around server.test.ts:3293) |
Eligible config + correct Origin + loopback Host → 200; credential exchanges successfully and the resulting session has AuthAdministrativeScopes and subject dev-auto-bootstrap; subject excluded from pairing-link listing |
Missing/mismatched/absent Origin → 403 (including null Origin) | |
Raw Host parsing: localhost:13773, 127.0.0.1:13773, [::1]:13773, 127.5.5.5 → pass; evil.example, evil.example:13773, malformed/empty → 403 | |
Ineligible: no devUrl (prod) / non-loopback devUrl / desktop mode / remote-reachable policy → typed not-available (404), not SPA fallback HTML and not a 302 redirect | |
| GET on the path → method not allowed / not found (mint must not be a simple request) | |
Response headers: Cache-Control: no-store; CORS reflects only the dev origin, never * | |
| Real Vite-proxy security test: request through the dev proxy with a foreign Origin → 403 (proves the Origin gate holds where v2's Host/CORS guards failed) | |
| Startup | Web dev mode + eligible → browser target is the plain dev URL and no startup pairing credential is minted |
| Prod / headless / remote / desktop → startup behavior unchanged (token still minted where it is today) | |
| Web gate | Unauthenticated + mint succeeds → exchange runs → gate resolves authenticated without pairing-required rendering |
Mint 404/network error → falls through to pairing-required silently; attempt state resets with the existing bootstrap reset paths (reauth/backend swap) | |
Explicit #token in URL → silent path skipped, token consumed via the normal flow | |
| Hosted-static and desktop-bridge contexts → no attempt made | |
| Manual | Incognito window on a running dev instance lands authenticated with no interaction; Settings → Connections fully functional (admin scopes); second parallel instance's tab likewise; curl -X POST with a foreign Origin gets 403 |
vp check and vp run typecheck green per AGENTS.md.
11. Docs / AGENTS.md
Short additions, high leverage for agents:
- AGENTS.md — new "Verifying changes in the browser" section:
- Starting a dev server to verify changes is expected and allowed:
T3CODE_DEV_INSTANCE=<worktree-name> bun run devpicks non-conflicting ports deterministically (server 13773+, web 5733+; runner probes for free ports and prints the chosen pair). - On localhost dev servers the web UI authenticates automatically — no pairing code needed; just load the web port.
- Dev instances sharing the default
T3CODE_HOMEshare state; a browser session carries across instances on the same hostname (uselocalhostconsistently — it does not share cookies with127.0.0.1).
- Starting a dev server to verify changes is expected and allowed:
docs/reference/scripts.md: extend "Running multiple dev instances" with the auto-auth note and the hostname/cookie qualification; fix the staleT3CODE_STATE_DIRreference while in there (the env var no longer exists — state dir derives fromT3CODE_HOME).docs/cloud/environment-auth.md: one section documenting the dev fast-path, its guards (Origin-checked POST, loopback-browser policy, loopback devUrl, Host parse), the admin-scopes decision, and the startup-flow change.
12. Open questions & scope
| Item | Status |
|---|---|
vite-plus host checking. Pin server.allowedHosts in apps/web/vite.config.ts explicitly (defense in depth for the proxy path; the Origin gate is the load-bearing control). Verify vite-plus inherits Vite's post-CVE-2025-30208 default Host checking. | verify during impl |
Contract shape. Exact success/error schema names and whether not-available maps to the existing environment-HTTP 404 error family — decide when touching packages/contracts/src/environmentHttp.ts. | decide during impl |
| Expired pairing-link pruning. Rows accumulate (filtered from listings, never deleted). Origin-gated POST removes the unauthorized-write vector, so this is general hygiene, not a blocker. | optional follow-up |
Size estimate (revised after review)
- Contracts: endpoint declaration + schemas — ~30–50 lines
- Server: handler + eligibility/Origin/Host guards + dedicated issuance method + extracted host parser + startup-flow change — ~120–180 lines
- Web: bootstrap-integrated silent attempt + guards — ~40–60 lines
- Tests: server (incl. real Vite-proxy test), startup, web gate — ~250–400 lines (the bulk, and the part that must not regress)
- Docs: ~25 lines
Pairing UX, grant store, session store, CORS layer, desktop/prod/remote flows: untouched. The startup browser-open path changes in dev only (§6).