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).
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.
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
- A fresh 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 dev server shows a 6-digit code in the terminal —
auth.printBanner()once listening. - 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 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
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 method | Direction | Shape |
|---|---|---|
anonymous:devframe:auth | client → server | { authToken, ua, origin } → { isTrusted } — re-authenticate a stored token |
anonymous:devframe:auth:exchange | client → server | { code, ua, origin } → { authToken | null } — exchange a code for a token |
devframe:auth:revoke | client → server | self-revoke the caller's own token |
devframe:auth:revoked | server → client | event — token revoked |
Node primitives (devframe/node/auth):
| Function | Role |
|---|---|
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).
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).
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 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
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.