---
title: "Security"
description: "Devframe tools are secure by default: connections bind to localhost, and dev-mode RPC requires a trust handshake before accepting a browser."
canonical_url: "https://devfra.me/guide/security"
---
# 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`.

> [!WARNING]
> `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`](https://devfra.me/errors/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

```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

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](https://devfra.me/references/node-api#auth-methods).

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](https://devfra.me/references/node-api#node-auth-primitives).

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=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.
- **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup misses it. To run your own check first, such as the peer address from `req.socket.remoteAddress` or a session cookie, leave `server` unset and own the listener. It sees every upgrade on the server, so act only on requests to `<base>__ws` and leave the rest to the host framework. For those, attach an `error` handler to the socket, then call `devtools.handleUpgrade(req, socket, head)` when the check passes, or write a `403` response and destroy the socket when it fails. The built-in socket gate checks `Origin` and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket.
- **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](https://devfra.me/adapters/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.


## Sitemap

See the full [sitemap](https://devfra.me/sitemap.md) for all pages.
