MDK Logo

Site security blueprint

Steps, options, and suggestions for securing an enterprise MDK site assembled from UI, Gateway, Kernel, and Workers

Backend keys identify processes, not the people using them. Supply a Gateway authVerifier backed by your identity system for "auth": true routes; other routes do not require it. Kernel checks caller keys and roles. Workers check caller keys without roles, and a Kernel role does not follow a key onto a direct Worker connection. Automatic startup can grant access to both. Review those permissions as well as user authentication, TLS, and network policy. Treat mvp-site and per-family snippets as boot demos, not a production template.

TL;DR

DecisionSuggested production choiceDemo-only choice
TopologyMicroservices over DHT, three networks (operator / backend / devices)Single process on loopback
Kernel admissionauth.whitelist with persistent Gateway and MCP keys, narrowed by roleallowAll: true
Worker accessExplicit allowedKeys; Kernel client key only if all requests must pass through Kernel; review automatic additionsSame host does not replace the allowlist
User identityYour SSO or JWTs, checked in the Gateway authVerifierNo verifier, noAuth()
WritesApproval-gated for fleet changes; Gateway sets authPermsDirect sendCommand with no token
UIHTTPS, bearerTokenAuth or working gatewayRedirectAuth, route guardsVite proxy, noAuth(), HashRouter
MCP / agentLoopback, human approval, real userIdDisabled, or loopback with local operator
SecretsSecret managerExample admin passwords

Overview

Walk the steps to understand how to secure an enterprise site you assemble from MDK: UI, Gateway, Kernel, and Workers on separate hosts or containers. MDK leaves these controls to you on purpose. The SDK coordinates devices; your site owns who may talk to it.

This page presents what to do, the options MDK gives you, and a suggested default, you will learn about:

The companion pages, security boundaries (what each layer trusts) and the control plane (how a request travels) cover related concepts.

Constraints the SDK does not lift

Build around these. They are inherent to MDK, not optional extras.

LayerWhat MDK doesWhat you own
UIToken attachment if you pass an AuthProvider; no route guard is applied for you (RequireAuth in the devkit is opt-in)Session, HTTPS, apiBaseUrl, hiding write controls
GatewayHTTP router for apps; runs no app code. "auth": true routes answer 401 without an authVerifier; Host allowlist (loopback), rate limit, Helmet headers, body limit on by default.authVerifier backed by your identity layer, allowedHosts
App runtimeRuns one app's controllers in its own process. Accepts HRPC callers in allowedKeys / allowedKeyFiles; no admitted keys means no access. Uses a separate Kernel caller key, granted full access by automatic directory admission. Manifest permissions are not enforced.Explicit incoming callers, Kernel roles for the app's own key, and reviewing the app before installing it
KernelEncrypted HRPC. Fail-closed caller allowlist (auth.whitelist; empty admits nobody) with read / write / admin roles. Requester and non-admin voter are the transport key. authPerms only on approval-gated writes, taken from the caller.Allowlist entries and roles, mapping verified users to authPerms / voter on the Gateway
WorkersNoise transport and key-addressed HRPC. Fail-closed allowedKeys; every admitted key can send supported requests, including commands. No caller roles or human actor in handler context.Choose direct callers explicitly, review automatic admission, isolate devices, validate payloads, and audit upstream
MCP / agentPOST /mcp on 127.0.0.1, no user auth by default (optional bearer token). Agent app binds to local without an identity layer.Bind policy, human approval for writes, real userId
Devices / poolsVendor protocol APIs and authenticationDevice-network isolation

For operator-facing access, use the Gateway or the standalone MCP server. Keep backend private keys and the Node.js client SDK out of the browser. Backend scripts can call Kernel or a Worker directly through the client API; the callee's allowlist determines whether the call is admitted.

Blueprint

Step 1: Choose a network layout

Pick a deployment topology before you write auth code. Isolation is the first control; identity sits on top of it.

Options

OptionWhen to pick itTrade-off
Single processLocal demos, tests, smallest footprintOne heap. A compromise of the process is the whole site.
Local (one OS process per service, shared directory)One machine, production-like restarts, or a step toward microservicesStronger isolation than a single heap. Kernel and Workers still share the host. No discovery topic; HRPC still runs over HyperDHT. Shared directories participate in admission.
Microservices over DHTEnterprise sites: separate hosts or containers, resource limits per service, Workers away from KernelPeers that know the discovery topic can find Kernel and Workers. Key distribution and a Kernel allowlist are mandatory.

Suggested default: microservices (one process or container per service, discovery over DHT), with three networks you define:

  • Operator: UI origin and Gateway HTTP, behind TLS and a reverse proxy you control
  • Backend: Kernel and Workers on separate hosts, no inbound path from browsers or agents
  • Devices: miner, container, meter, and pool APIs, reachable only from the Workers that own them

Pass the Kernel public key to a remote Gateway as kernelKey. Treat the DHT discovery topic as deployment configuration, not a credential: generate a site-specific topic, distribute it out of band, and do not reuse example or well-known values. Pair this shape with explicit Kernel caller admission and pinned Worker keys for topic discovery. Knowing a topic or a listener key does not grant access: unlisted callers are rejected unless allowAll is enabled.

Do not pass @tetherto/mdk-client or Kernel keys into the browser. HTTP to the Gateway is the only operator path.

Step 2: Admit the Gateway to Kernel

auth.whitelist defaults to [], which admits nobody before startup helpers add keys. Kernel then checks each admitted caller's role on every request; the Kernel role reference lists the actions each of read, write and admin permits. A plain key or kernel.allowKey(key) without roles grants admin.

Options

OptionWhen to pick itTrade-off
allowAll: trueThrowaway local experiments onlyAnyone with the Kernel public key can read telemetry and dispatch commands, skipping the Gateway. Logs a warning.
Allowlist the Gateway DHT public keyEvery real siteThe Gateway store holds that key's seed: guard it like a credential
Allowlist Gateway plus extra backend clientsA second mdk-client service you trust (batch jobs, a second Gateway, the standalone MCP server)A plain key is a full (admin) Kernel principal; use { key, roles: ['read'] } or ['write'] to narrow it. Review it like a production credential.

Suggested default: admit only the persistent backend caller keys the site needs, with explicit roles. Disable automatic directory admission when enforcing that list. Gateway clients, app runtimes, and MCP clients may each use separate keys.

Start a programmatic host with explicit admission and directory-based admission disabled:

const kernel = await getKernel({
  trustedKeysDir: false,
  hrpc: {
    whitelist: [{ key: '<caller-public-key-hex>', roles: ['read'] }]
  }
})

Choose roles per backend caller, widen a caller to write only when it needs to change devices. Gateway clients use the persistent status.rpcClientKey from the Gateway handle or status file. An app runtime uses its own Kernel caller key, so admitting a Gateway key does not grant an app runtime access. Treat each store containing identity seeds as a private key: copying it can allow impersonation.

Automatic admission during startup

Inspect the startup admission helpers before relying on a restricted allowlist. The trusted-keys directory stores public keys, but publishing a key there is an authorization action for the processes watching it.

Startup pathKernel caller grantsWorker caller grants
getKernel() with directory admission enabledAdmits every valid published caller key except its own client key with admin; this can replace an explicit narrower roleThis function does not configure Worker allowlists
startGateway({ kernel })Calls kernel.allowKey(gatewayKey) with full access, even if directory publishing is disabledAn attached trustKernel() helper mirrors write-capable grants
CLI Worker with a live Kernel, or a host using trustKernel(runtime, kernel)Worker trust does not alter Kernel rolesAdmits Kernel's client key and Kernel callers holding write or admin, now and on later grants
CLI Worker in a separate process using trustKeysFromDir()CLI Kernel startup uses the directory admission described hereAdmits every valid published key without roles, including a key restricted to read on Kernel
Supervised Worker through worker.jsDoes not start KernelCombines MDK_WORKER_ALLOWED_KEYS with default trusted-directory keys and follows later directory additions

getKernel() uses <root>/.trusted-keys by default. The CLI supplies its project-local directory; supervised Workers use DEFAULT_TRUSTED_KEYS_DIR (~/.mdk/.trusted-keys). Grant directory write access only to processes you trust to grant backend access. Filesystem protections restrict who can publish a key; they do not assign that key a narrower role.

For explicit Kernel roles, use trustedKeysDir: false on getKernel(). To avoid the additional in-process grant, start the Gateway with kernelKey rather than the live kernel handle, disable its directory publishing with trustedKeysDir: false, and admit its caller key explicitly. Passing trustedKeysDir: false only to startGateway({ kernel }) does not prevent its full-access grant. Manage MCP and app runtime keys explicitly as well.

The standalone MCP server (mdk run mcp) is a separate Kernel caller with its own persistent key. mdk run publishes it to the trusted-keys dir; on a multi-host site add it to auth.whitelist alongside the Gateway, or its tools can't reach Kernel.

Each app runtime is a Kernel caller too, with its own persistent key: mdk run publishes it as app-<name>.pub in the trusted-keys dir, next to kernel.pub, gateway.pub, mcp.pub and cli.pub. The directory watcher admits valid caller keys with full access, excluding Kernel's own client key. An app's permissions in mdk-app.yaml are not enforced by Kernel yet, so review an app before you add it to a stack.

Worker discovery admission

The list of Workers a Kernel may discover is separate from the list of clients allowed to call Kernel. For DHT-topic discovery, only Worker RPC keys on discovery.workerKeys (or added with kernel.allowWorkerKey()) register, and an empty list admits nobody. discovery.allowAnyWorker: true admits every peer that knows the topic; like allowAll, it is for throwaway local experiments only and logs a warning. With it, a peer with the topic could register as a Worker and receive the commands, including credentials, for its device family.

Local shared-directory discovery and explicit kernel.registerWorker() skip that topic allowlist. Control who can publish Worker keys or call registration; adding discovery.workerKeys does not restrict those paths.

On separate hosts, pass Kernel's public key to the Gateway as kernelKey. Do not rely on the well-known key file (~/.mdk/.kernel-key) across machines. Where that file still exists on the Kernel host, it holds the public key, written world-readable (0644) inside a directory you own; it is not deleted on shutdown. Do not expose Kernel's HRPC listener off the backend network.

Step 3: Isolate Workers and devices

WorkerRuntime rejects unlisted caller keys at connection time and checks the key again on every request. With allowAll disabled, it refuses to start with an empty allowedKeys list. This is a key-based firewall, not a list of allowed server addresses. An admitted key can send any request the Worker supports, including command.request; the list has no read-only role.

Choose which request paths the site allows:

PathChecks that applyConsequence
Client to Kernel to WorkerKernel caller key and action role, Kernel command handling, then the Worker's allowlist for Kernel's client keyKernel mediates the request; approval runs only for the approval-gated action path
Client directly to WorkerWorker's caller allowlist, request validation, and Worker handlersKernel roles, command lifecycle records, and its approval flow do not run

The direct Worker client, sendWorkerCommand() and pullWorkerTelemetry() use the second path. Resolving a Worker's public key through Kernel does not make the following Worker call Kernel-mediated. The caller still needs a key admitted by that Worker. This is separately granted access, not a way around the Worker's firewall.

For example, a monitoring script admitted to Kernel as read cannot send a command through Kernel. If its key is also on a Worker's allowlist, the same script can send supported commands directly to that Worker. To keep it read-only, restrict both paths.

For a site that requires all device requests to pass through Kernel, set each Worker's allowedKeys to only kernel.getClientPublicKey(), not Kernel's listener key from getPublicKey(). Do not add the Gateway, MCP, app runtime, or script key to that list. If the site deliberately needs direct provisioning or Worker queries, admit the required caller explicitly and treat it as able to issue supported commands too.

An initial allowedKeys list is not an exclusive policy when a trust helper adds more keys afterward. The CLI installs trustKernel() / trustKeysFromDir() automatically, and worker.js adds trusted-directory keys even when MDK_WORKER_ALLOWED_KEYS is set (additive, not an override). Kernel-only access needs a programmatic host with explicit Worker boot options and neither helper.

Keep the Worker firewall enabled and isolate the network as an additional control. Security boundaries explain the distinction between endpoint identity, key admission, and user authentication.

Options

OptionWhen to pick itTrade-off
Workers only on the Kernel network, keys unpublishedEvery siteOperational discipline: no Worker port or DHT topic on the operator network
One Worker process per trust zoneDifferent device families or vendors must not share credentialsMore processes to supervise
Device APIs on a third network (CGMiner 4028, miner HTTP, container APIs)Real hardwareRequired when the vendor protocol has weak or no auth (Avalon / CGMiner have none; Antminer uses digest auth; Whatsminer uses a device password)

Suggested default: persist each Worker's storeDir so its key is stable, treat that key as a machine identity, and register the Worker with Kernel only after the device network is isolated. On DHT, keep Worker public keys and the discovery topic off the operator network and out of git. Inject device passwords and pool API keys from a secret manager or protected environment. Never put secrets in mdk-contract.json, git, or a committed seed file.

Validate command payloads again in the handler (types, ranges, allowed targets). Kernel checks the command name against the contract; it does not know your hardware's unsafe parameter combinations. Redact credentials and vendor responses in handler errors and logs. For CGMiner-style devices, network isolation is the control: changing the Worker password field does not add a wire handshake the protocol lacks.

Step 4: Put identity in the Gateway

The Gateway is the only supported user-auth seam. Pass startGateway() an authVerifier(request, route) backed by your identity layer: routes a manifest marks "auth": true answer 401 unless it returns an identity, which controllers read as req._info. Routes without "auth": true are served to any caller. Paths under /auth/metrics/* are historical names, not a login gate.

Options

OptionWhen to pick itTrade-off
Your SSO (OIDC / SAML / existing IdP), validated in the authVerifierProduction sites with an identity providerYou write the verifier and mark routes "auth": true: this is the supported path
Gateway-issued JWT after an OAuth redirect you implement (/oauth/... → ?authToken=, plus POST /auth/token and /auth/userinfo)Browser UI using gatewayRedirectAuth({ oauthBaseUrl })You still write those routes
Bearer tokens only (Authorization: Bearer), no browser redirectService accounts, scripts, or a UI that already has a tokenPair with bearerTokenAuth() in the UI
No identity (noAuth(), no authVerifier)Laptop demos bound to loopback"auth": true routes stay closed; every other route is open to anyone who can reach the port

Suggested default: one authVerifier for the whole Gateway, and "auth": true on every route that reads or changes the fleet. Map verified roles onto Kernel authPerms (miner:w, container:w, and any others you define). Never accept authPerms or voter from the HTTP client. Return false for a missing or bad token (401) and throw a 403 ERR_* error for a missing permission.

// The verifier does the check; the Gateway apps guide owns the full example.
authVerifier: async (request, route) => {
  const identity = await verify(request.headers.authorization)
  if (!identity) return false
  if (!(route.permissions || []).every(p => identity.perms.includes(p))) {
    throw Object.assign(new Error('ERR_AUTH_PERM_DENIED'), { statusCode: 403 })
  }
  return identity
}

The Gateway sanitizes error bodies: only ERR_* codes and Fastify's own 4xx errors reach the client verbatim; the Gateway security model details the shape and what gets logged instead.

The Gateway apps guide owns this pattern. Also:

  • Bind HTTP to the proxy network, not a public interface without that proxy
  • List the names clients use in allowedHosts; only loopback on the bound port passes the Host check by default
  • Tune rateLimit for writes. ?overwriteCache=true is honored only for an identity whose perms holds cache:overwrite
  • trustProxy is off by default. Turn it on only behind a proxy whose hop count you trust, or client IPs (and so rate limits) can be spoofed
  • Log actor, route, target, command, outcome, and a correlation id for every write. Redact secrets. Worker handlers cannot see actor identity, so this audit stays in the Gateway

Step 5: Authorize writes

Kernel distinguishes a command sent to Kernel (command.request) from an approval-gated action (action.push and votes). Both require a Kernel caller role of write or admin. A command sent directly to a Worker is a third path with only the Worker checks described in Step 3. Choose the path per action and enforce that choice at every entry point you admit.

Kernel's role check authorizes the backend process, not the person behind an HTTP request. For commands sent to Kernel, the Gateway or app controller must authorize that person. Approval-gated writes also check authPerms, but the admitted caller supplies that array; derive it from verified identity. Non-admin callers vote as their transport key; an admin caller may forward a user's voter identity. Grant that delegation only to a process trusted to authenticate those users.

Options

OptionWhen to pick itTrade-off
Command to Kernel (command.request) after a controller permission checkLow-impact, operator-in-front actions (LED, a single-device reboot you already authorized)Requires a write-capable backend key; no approval vote or human-identity check runs in Kernel
Approval-gated write (action.push, then votes)Fleet-changing actions: power mode, pool assignment, container PDU, bulk rebootSlower. Still requires the Gateway to set authPerms and voter from the verified token. The write-actions guide covers the HTTP side.
Agent-proposed write with a human approval pauseOperator agent (approvalTimeoutMs on @tetherto/mdk-app-agent)The model cannot execute a write until someone approves. Without identity, every request is the same local operator.

Suggested default: approval-gated writes for anything that changes hash, power, or network membership; direct commands only behind a controller that already checked a token. Do not expose a raw sendCommand route to the UI without that check.

Step 6: Wire the UI session

<MdkProvider auth={...}> is how the browser attaches a credential. The toolkit does not guard routes or deny writes on its own.

Options

OptionWhen to pick itTrade-off
noAuth()Local dev against a Gateway without an auth plugin, demosNo token. A finished-looking dashboard still sends unauthenticated calls.
bearerTokenAuth()You already issue JWTs from the Gateway identity pluginSession ends on 401. You own login UI.
gatewayRedirectAuth({ oauthBaseUrl })OAuth redirect, ?authToken= capture, refresh against POST /auth/tokenNeeds your token routes. Default MdkProvider auth without oauthBaseUrl cannot sign in.
Custom AuthProviderA session model the three presets do not coverYou implement getToken, refresh, and sign-out

Suggested default for a production dashboard:

  • <MdkProvider apiBaseUrl={httpsOrigin} auth={bearerTokenAuth()}> or gatewayRedirectAuth({ oauthBaseUrl }) after those routes exist
  • HTTPS, a real apiBaseUrl, no Vite dev proxy
  • One fetch client so pages cannot skip Authorization
  • Route guards on any view that can mutate the fleet (the shell template uses <RequireAuth>; copy that idea, not noAuth())
  • Hide write controls unless the token carries the matching permission. Hiding is not authorization; the Gateway still denies
  • Scrub ?authToken= from the URL after capture. Tokens in query strings leak through access logs and Referer
  • Static bundle behind the same proxy that terminates TLS. Do not run vite dev as the production UI

The mvp-site example UI has no sign-in, no session, and a hardcoded profile. Do not copy that into a reachable host.

Step 7: (Optional) MCP and the operator agent

MCP is optional. When you enable it, it has the same authority as the process that hosts it. The standalone @tetherto/mdk-mcp process binds 127.0.0.1 and answers POST /mcp with no user auth by default; an optional bearer token gates every call. The Gateway hosts no MCP itself.

Options

OptionWhen to pick itTrade-off
No MCP, no agentDashboards and scripts onlySmallest surface
MCP on loopback, operator agent on the GatewayIn-process chat for people who already passed Gateway identityKeep approvalTimeoutMs on. Pass a real userId into createSession / resumeSession. A session id in a URL must not read another operator's chat
MCP or agent published past localhostAn agent runtime on another hostMCP does not identify individual users; its optional bearer token grants shared access. Put mutual TLS or your SSO proxy in front, and authorize inside the reused HTTP handlers (an app's routes served as MCP tools run the same handlers under one server-wide identity, with no per-user check; auth: true tools stay hidden unless that identity is configured)

Suggested default: leave MCP on 127.0.0.1. Do not publish it with compose ports:, an SSH tunnel, or a reverse proxy unless that front door is authenticated. Treat the model provider (qvac or an OpenAI-compatible URL) as sensitive infrastructure: prompts can include site topology and, if you are careless, secrets.

Step 8: Handle secrets, stores, and day-two operations

Options for secrets

OptionWhen to pick itTrade-off
Secret manager (Vault, cloud SM, Kubernetes secrets) into the host processProductionExtra moving part. This is the suggested default.
Protected environment on a locked-down hostSmall single-host sitesRotation and audit are your scripts
Values in git, mdk-contract.json, example site.deploy.json, or the UI bundleNeverCredentials leak with the repo or the browser

Options for process lifecycle

OptionWhen to pick itTrade-off
SIGINT / supervisor stop so WAL and cleanup runAlwaysA SIGKILL can leave .mdk-data / storeDir unusable. The next boot may look healthy with zero Workers.
Health check HTTP only (an HTTP route the Gateway serves)InsufficientThe Gateway keeps serving HTTP while the Kernel is unreachable; Kernel-backed routes fail with ERR_MDK_CLIENT_UNAVAILABLE until it is back.

Suggested default:

  • Mode 0700 (or equivalent) on the Kernel db directory and Worker storeDir, and 0600 on seed and key files. Encrypt at rest if the host requires it.
  • Back up those directories as operational data, with the same access control as the live files
  • Rotate keys when access changes, and update every callee that admits them, including direct Worker grants
  • Practice recovery from a wiped data directory before an incident
  • Watch Gateway-to-Kernel errors, not only the HTTP port
  • Run Node.js >=24. Pin lockfiles. Prefer non-root containers with a read-only root filesystem
  • Stay on the latest main or latest tag you have reviewed. 0.y.z is initial development; older tags may not receive security fixes. Report product issues through the security policy, not public GitHub issues

Revoke access on every admitted path

Trusted-directory watchers add keys; deleting a .pub file does not revoke its grants in running processes. Use kernel.revokeKey(key) and each affected runtime.revokeKey(key), and remove the publication and startup configuration that would grant it again. With allowAll enabled, removing an allowlist entry does not deny that caller; disable allowAll as well.

trustKernel() mirrors explicit Kernel revocations to attached Workers, but changing a Kernel role from write to read does not remove an existing Worker grant. A directory watcher does not mirror Kernel role changes or revocations. Check the live Kernel roles and Worker allowlists, not just files on disk, and verify that denied calls fail after revocation and after restart.

Next steps

  • Read the security boundaries: what each layer trusts, and what it does not
  • Follow the control plane: read path, direct command, and approval-gated write
  • Add identity in app controllers: the only supported user-auth seam
  • Restrict Kernel callers with the HRPC allowlist: transport admission for the Gateway
  • Compare deployment topologies: this blueprint assumes microservices; local and single-process are smaller footprints
  • Apply the blueprint as values with the hosted-site reference configuration: two profiles and one settings table
  • Report product vulnerabilities through the security policy: private advisory, not a public issue

On this page