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 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).
authdefaults totrue; the browser authenticates before calls are accepted, then reconnects with a node-issued bearer token.createInteractiveAuth(devframe/recipes/interactive-auth) packages the protocol into oneDevframeAuthHandlerthe adapters wire for you (pass it toinitDevframe/initHubviaauth). - Unauthenticated opt-out.
auth: falsestarts the server with an auto-trust handshake, for single-user tools on their ownlocalhost.
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
- A fresh RPC client calls
anonymous:devframe:authwith its stored token (empty on first run); the server returns{ isTrusted: false }and the UI prompts for a code. - 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. - The developer enters it; the browser calls
requestTrustWithCode(code). - The server verifies the code, mints a high-entropy bearer token, trusts the session, and returns it.
- 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 thedevframe-authchannel 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).
Magic-link authentication
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=123456The 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: falselocal. The hosted bridges (devframeViteBridge,@devframes/next's handler) gate their side-car by default; opt out with an explicitauth: falseonly 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 loopbackOrigin(the socket address can't be forged the way a header can). So the'auto'default - which mounts the route once agent tools exist - andmcp: trueare enough for a local dev tool. Neither gate proves which caller it is, though, so to intentionally reach the route beyond loopback (a widenedallowedOrigins, a hosted app) or to expose destructive tools, add an identity check withmcp: { authorization }(a bearer from an env var, or a callback), which also lifts the loopback-peer restriction - or turn the route off withmcp: 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
originLockon (the default) so its session token is only honored on a connection whoseOriginmatches 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.
Transports
Devframe serves live RPC over two interchangeable transports, WebSocket and SSE, so an RPC client connects even where the WebSocket upgrade is unavailable (serverless, buffering proxies). Both speak the identical birpc wire protocol, transparent to your RPC code.
Agent-Native Devframe
Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and WebMCP on the browser side, opt-in per function.