Skip to content

Security ​

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 host 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.

WARNING

auth: false trusts every connection that can reach the port. Only use it when the surface 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 two handshake methods below.

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 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 dev server shows a 6-digit code in the terminal — auth.printBanner() once listening.
  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 surface is reachable beyond loopback. A client self-revokes (devframe:auth:revoke) or a host revokes it (revokeAuthToken); affected clients drop to untrusted via devframe:auth:revoked.

The ready-made layer ​

ts
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 ​

RPC methodDirectionShape
anonymous:devframe:authclient → server{ authToken, ua, origin } → { isTrusted } — re-authenticate a stored token
anonymous:devframe:auth:exchangeclient → server{ code, ua, origin } → { authToken | null } — exchange a code for a token
devframe:auth:revokeclient → serverself-revoke the caller's own token
devframe:auth:revokedserver → clientevent — token revoked

Node primitives (devframe/node/auth):

FunctionRole
getTempAuthCode() / refreshTempAuthCode()read / rotate the one-time code
exchangeTempAuthCode(code, session, { ua, origin }, storage)verify a code, mint + store the token, trust the session, return it (or null)
verifyAuthToken(token, session, storage)trust a session presenting a known token
buildOtpAuthUrl(origin, code?)build a magic-link URL embedding the code
revokeAuthToken(context, storage, token)delete a token and disconnect sessions using it

Client methods (devframe/client): 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).

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 owns the trust boundary another way.
  • The MCP route requires an origin. The route-based MCP server rejects requests without a loopback or allow-listed Origin, so an arbitrary local process can't reach it — 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:

ts
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.

Released under the MIT License.