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.

Revision 3 — incorporates independent Codex (gpt-5.6-sol) design review. Key changes from v2: the mint request is now an Origin-checked non-simple POST (the Vite dev proxy defeats both CORS-opacity and the Host guard, so Origin is the primary browser-facing gate); server guard is exactly 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.
Contents
  1. Problem & goals
  2. Threat model — why not just disable auth
  3. Auth boundary: Origin as primary gate, CORS as backstop
  4. Complete flow
  5. Server changes
  6. Startup flow change (required)
  7. Web changes
  8. Attack analysis
  9. Parallel dev instances
  10. Test plan
  11. Docs / AGENTS.md
  12. Open questions & scope

1. Problem & goals

The dev startup pairing credential is one-time and 5-minute TTL (PairingGrantStore.tsDEFAULT_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.

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:

  1. 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 exactly loopback-browser, devUrl set and devUrl.hostname loopback. This excludes prod (no devUrl), desktop dev (desktop-managed-local policy — 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).
  2. Request authentication (per-request, primary): non-simple POST (JSON body → preflighted) requiring Origin to exactly equal devUrl.origin, and the parsed request Host to be loopback. Browsers attach Origin to 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.
  3. Response opacity (backstop): the existing browserApiCorsLayer allowlists only devUrl.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

Browser — dev web app http://localhost:5733 Vite dev server :5733 /api proxy → backend t3 server :13773 web dev mode, loopback bind 1. App loads, auth gate sees unauthenticated; no explicit #token in URL 2. POST /api/auth/dev-pairing Origin: http://localhost:5733 3. proxy → 127.0.0.1:13773 Origin forwarded unmodified 4. Guards (all must pass): ✓ mode = web, devUrl set ✓ devUrl.hostname loopback ✓ policy = loopback-browser ✓ Origin = devUrl.origin ✓ parsed Host is loopback 5. Mint one-time credential 5-min TTL, admin scopes, subject dev-auto-bootstrap 6. { credential } — CORS additionally limits readability to the allowlisted dev origin 7. Existing exchange: exchangeBootstrapCredential() 8. Consume grant, issue session Set-Cookie: t3_session 9. Authenticated; app connects Attacker paths (webpage in the same machine's browser, or a LAN device) Drive-by: fetch/POST from evil.example directly to localhost:13773 → blocked: Origin "https://evil.example" ≠ dev origin (403); response also CORS-opaque DNS rebinding: evil.example → 127.0.0.1, "same-origin" POST, Host: evil.example → blocked: Origin ≠ dev origin, and Host is not loopback (403) Via Vite proxy: LAN device or rebound page hits :5733/api/… (Host rewritten to loopback) → blocked: browser-attached Origin survives the proxy and ≠ dev origin (403) new WebSocket('ws://localhost:13773/…') → blocked (unchanged): no session cookie / WS ticket — upgrade refused
Happy path (top) and blocked attack paths (bottom). Steps 7–9 are the existing pairing exchange, untouched.

Sequence in words:

1Fresh browser context loads 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.
2New: the gate issues 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.
3New: server validates eligibility + Origin + Host (§5), then mints a one-time 5-minute credential with administrative scopes under subject dev-auto-bootstrap.
4Client feeds the credential into the existing exchange path (exchangeBootstrapCredential in apps/web/src/environments/primary/auth.ts:231) — the same client function the /pair screen uses. Session cookie set; grant consumed.
5Gate re-resolves → authenticated → app connects over WS with a ticket. On any failure (guard rejection, prod, race), fall through to the normal /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)

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:

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.

8. Attack analysis

AttackPathOutcome
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):

10. Test plan

AreaTest
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
ManualIncognito 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:

12. Open questions & scope

ItemStatus
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)

Pairing UX, grant store, session store, CORS layer, desktop/prod/remote flows: untouched. The startup browser-open path changes in dev only (§6).