Security

Devframe tools are secure by default: connections bind to localhost, and dev-mode RPC requires a trust handshake before accepting a browser.

Devframe tools are secure by default: connections bind to localhost, and dev-mode RPC requires a trust handshake before accepting a browser.

Trust model

An RPC handler runs with the full privileges of its Node process (filesystem, child processes, network), and a trusted connection can call any registered function. The boundary that matters is who may connect:

  • Authenticated (default). auth defaults to true; the browser authenticates before calls are accepted, then reconnects with a node-issued bearer token. createInteractiveAuth (devframe/recipes/interactive-auth) packages the protocol into one DevframeAuthHandler the adapters wire for you (pass it to initDevframe / initHub via auth).
  • Unauthenticated opt-out. auth: false starts the server with an auto-trust handshake, for single-user tools on their own localhost.
auth: false trusts every connection that can reach the port. Only use it when the endpoint is reachable solely by the local developer. Never combine it with a non-loopback bind host, a tunnelled port, or a shared/CI environment.

The pre-trust gate

One rule decides what an untrusted connection may call: a method is reachable before trust iff its name starts with anonymous: (isAnonymousRpcMethod, from devframe/constants); only the handshake and code-request methods below qualify.

The RPC server binding enforces this: pass auth: authHandler (its .authorize becomes the gate) or your own authorize(methodName, session). Every other call from an untrusted session throws DF0036. rpc.call / rpc.callOptional / rpc.callEvent hold calls issued during the first handshake and release them once it settles.

Authentication flow

  1. A fresh RPC client calls anonymous:devframe:auth with its stored token (empty on first run); the server returns { isTrusted: false } and the UI prompts for a code.
  2. The auth UI requests a code (rpc.requestAuthCode(), sent automatically when the built-in notice view first shows, or by its "re-issue" button with { reissue: true } to rotate the code first); the dev server prints the 6-digit code, its expiry, and the requesting browser in the terminal. An already-authorized page never triggers a print.
  3. The developer enters it; the browser calls requestTrustWithCode(code).
  4. The server verifies the code, mints a high-entropy bearer token, trusts the session, and returns it.
  5. The browser persists the token and presents it on reconnect (or via a ?devframe_auth_token= query param the connect-time hook checks first); sibling tabs receive it over the devframe-auth channel and become trusted.

The 6-digit code is single-use, expires after five minutes, is compared in constant time, and rotates after repeated wrong attempts. Show it only in a trusted channel (the terminal), never over the network.

The bearer token is a secret. It travels to the server on the WebSocket URL (?devframe_auth_token=…), so serve over wss:///https:// whenever the endpoint is reachable beyond loopback. An RPC client self-revokes (devframe:auth:revoke) or the node side revokes it (revokeAuthToken); affected RPC clients drop to untrusted via devframe:auth:revoked.

The ready-made layer

import { createInteractiveAuth } from 'devframe/recipes/interactive-auth'

// The adapters gate with this layer by default. Construct it yourself only to
// tune it (e.g. CI tokens) and hand it to `initDevframe` / `initHub` as `auth`.
const auth = createInteractiveAuth(ctx, {
  clientAuthTokens: process.env.CI ? [process.env.DEVFRAME_CI_TOKEN!] : undefined,
})

Pass clientAuthTokens for CI/shared machines to skip the prompt, or a custom banner/serverUrl.

Auth methods

The anonymous:-prefixed methods re-authenticate a stored token (anonymous:devframe:auth), exchange a one-time code for a token (anonymous:devframe:auth:exchange), and ask the server to print its code banner (anonymous:devframe:auth:request-code); devframe:auth:revoke self-revokes, and the devframe:auth:revoked event drops affected RPC clients to untrusted. Wire shapes are in the Node-Side API reference.

Node primitives in devframe/node/auth (getTempAuthCode / refreshTempAuthCode, exchangeTempAuthCode, verifyAuthToken, buildOtpAuthUrl, and revokeAuthToken) implement the same flow for a host framework wiring its own gate; signatures are in the reference.

RPC client methods (devframe/client): requestAuthCode(options?) (print the code banner; { reissue: true } rotates the code first), requestTrustWithCode(code), requestTrustWithToken(token), and ensureTrusted(timeout?) / isTrusted (the trust gate).

The standalone CLI (createCac / createDevServer) prints a link embedding the code for --open, so the launched tab lands authenticated with no prompt. Build it yourself with buildOtpAuthUrl(origin):

Devtools ready. Authenticate this browser: http://localhost:3000/#devframe_otp=123456

The code rides the URL fragment (#devframe_otp=…), which browsers never send to the server, keeping the single-use code out of access logs and Referer headers. connectDevframe reads it, exchanges it, and strips it from the URL. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal).

The link points at the public origin. A standalone dev server derives it from its own bound address; an owned listener uses that address regardless of any inbound Host header. A handler or middleware without an explicit origin derives one from a request only when the request's own origin is loopback or exactly matches an allowedOrigins entry; a raw inbound authority and forwarded headers are never trusted. Set origin explicitly for non-loopback handler deployments (behind a proxy, on a LAN, or on a public host) so the magic link always resolves to the address you intend.

For your own auth UI, disable built-in handling with otpParam: false, then call authenticateWithUrlOtp(rpc) or consumeOtpFromUrl() from devframe/client.

Practices for tools built on devframe

  • Stay on loopback. Bind to a routable address only intentionally, and require authentication when you do.
  • Keep auth: false local. The hosted bridges (devframeViteBridge, @devframes/next's handler) gate their side-car by default; opt out with an explicit auth: false only when the host framework owns the trust boundary another way.
  • The MCP route trusts same-machine callers, harden it when that's not your boundary. Two gates enforce that default: an origin gate (loopback-only, Origin-less rejected) is browser DNS-rebinding hardening, and a peer-address gate rejects a non-loopback caller even with a forged loopback Origin (the socket address can't be forged the way a header can). So the 'auto' default - which mounts the route once agent tools exist - and mcp: true are enough for a local dev tool. Neither gate proves which caller it is, though, so to intentionally reach the route beyond loopback (a widened allowedOrigins, a hosted app) or to expose destructive tools, add an identity check with mcp: { authorization } (a bearer from an env var, or a callback), which also lifts the loopback-peer restriction - or turn the route off with mcp: false. See MCP.
  • Treat tokens as secrets. Never log the bearer token or the one-time code, or bake either into build output.
  • Authorize every handler. Validate inputs, and mark state-changing functions type: 'destructive' so MCP and agent clients prompt before invoking them.
  • Origin-lock remote docks. When a hub embeds a remote-UI dock, keep originLock on (the default) so its session token is only honored on a connection whose Origin matches the dock's own.

External viewer origins

WebSocket handshakes from browser extensions and other external viewers carry the viewer's own Origin header, authorized through a live registry:

import { attachWsRpcTransport, createWsOriginRegistry } from 'devframe/rpc/transports/ws-server'

const viewerOrigins = createWsOriginRegistry({
  validateOrigin: origin => origin.startsWith('chrome-extension://')
    || origin.startsWith('moz-extension://'),
})

attachWsRpcTransport(rpc, {
  server,
  allowedOrigins: viewerOrigins,
})

Include viewerOrigins.token as viewerOriginToken in the connection metadata. In the metadata handler, call viewerOrigins.registerFromUrl(request.url); when it returns an origin, set Access-Control-Allow-Origin to it. The external viewer then calls registerDevframeViewerOrigin(connection) before connecting.

The registration token grants access through the transport's origin check; RPC authentication still authorizes the session and every non-anonymous method. Keep metadata carrying this token same-origin until the registration is verified.