MDK Logo

Site security blueprint

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

MDK enforces user identity and an access policy at every entry point, and it fails closed: a stack whose spec.rbac binds nobody refuses every call. That policy, TLS, network isolation and secrets are still part of the site you build. Treat mvp-site and per-family snippets as boot demos, not a production template.

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.

Every caller signs in to the Kernel's token issuer and gets a short-lived access token. One policy, spec.rbac in mdk.yaml, decides what that identity may do, and the Gateway (users), the MCP server (AI agents) and the Kernel (apps and every other caller) evaluate it with the same RBAC library. Your site owns the policy's content and the network it runs on.

This page presents what to do, the options MDK gives you, and a suggested default. It covers:

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

What MDK enforces and what you own

Build around these. They are current 0.y.z behavior.

LayerWhat MDK doesWhat you own
Policyspec.rbac in mdk.yaml is the only place policy is written. Default deny, no superadmin (admin is an ordinary role holding wildcards), an invalid policy refused at loadThe roles and bindings, reviewed like code
UIpasswordAuth() signs in through the Gateway, keeps the access token in memory and the refresh token in an HttpOnly cookieHTTPS, a real apiBaseUrl, hiding write controls the user cannot use
GatewayServes /auth/* from the Kernel's token issuer. Checks a bearer token and a spec.rbac rule on every app route; no route is open. Host allowlist (loopback), rate limit, Helmet headers, body limit on by defaultTLS in front, allowedHosts, trustProxy behind a proxy you control
App runtimeRuns one app's controllers in its own process and admits only the Gateway's and MCP server's keys. Calls Kernel as service account app:<name>, holding only the grant its mdk-app.yaml permissions requested, and acting for the signed-in user through req.mdk so Kernel checks the user tooReviewing an app's code and requested grant before installing it, narrowing the grant
KernelAdmits a key to connect (allowlist or trusted-keys dir), which grants nothing. Checks the access token on every request but sign-in against spec.rbac, per action and per device. Records every command.request attemptAdmitting the right keys, the policy, guarding the Kernel store
WorkersNoise transport and key-addressed HRPC. Admit only the Kernel's client key, so every device call passes the Kernel's checks and write-command logNetwork isolation, device secrets, payload checks
MCP / agentPOST /mcp on 127.0.0.1. Each agent signs in as its own service account and lists only the tools its rules allowAgent accounts and their bindings, human approval for writes
Devices / poolsVendor protocol APIs and authenticationDevice-network isolation

Consumers enter through the Gateway; AI agents enter through the standalone MCP server. The browser never holds a Kernel key. Direct Kernel or Worker reachability is a backend-network concern, not a product feature.

Blueprint

Step 1: Choose a network layout

Pick a deployment topology before you write policy. Isolation is the first control; identity and policy sit 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, and the trusted-keys dir is the admission boundary. No DHT.
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, app runtimes, MCP server and Workers, 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 a Kernel allowlist that names every caller key.

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

Admission decides which keys may connect to Kernel. It is fail-closed (an empty allowlist admits nobody) and it grants nothing: what an admitted caller may do comes from its access token and spec.rbac. There are no allowlist roles. An entry written as { key, roles } is refused at start with ERR_KERNEL_CALLER_ROLES_UNSUPPORTED, and allowAll on the Kernel with ERR_KERNEL_ALLOW_ALL_REMOVED.

Options

OptionWhen to pick itTrade-off
In process (startGateway({ kernel }))Single process, or mdk runThe Gateway key is admitted for you. Nothing to distribute
Trusted-keys dirSeparate processes on one machineThe Kernel admits every <name>.pub in the dir. Anyone who can write the dir is admitted, so keep it 0700 and owned by the service user
Allowlist each key (hrpc.whitelist, kernel.allowKey())Every multi-host siteYou distribute the Gateway, MCP server and app runtime keys yourself, and update the list when a key rotates

Suggested default across hosts: one persistent key per caller, each hex public key on the Kernel's allowlist:

const kernel = await getKernel({
  hrpc: { whitelist: ['<gateway-key-hex>', '<mcp-key-hex>', '<app-runtime-key-hex>'] }
})

The Gateway derives its caller key from a seed persisted in its store, so the key survives restarts: read it from status.rpcClientKey in the Gateway status file. The standalone MCP server (mdk run mcp) and each app runtime have their own persistent keys too; mdk run publishes them to the trusted-keys dir (mcp.pub, app-<name>.pub). Treat each of those stores with the same care as a TLS key, since a copy of it presents that caller's transport identity.

Admission alone gets a caller nowhere. A Gateway or MCP server in its own process also signs in as its service account (mdk-gateway, mdk-mcp) to read the policy it enforces, which needs policy: read; mdk rbac init binds both to a gateway role holding exactly that. Each app runtime signs in as app:<name>. A service account's token is bound to the transport key that signed in, so a copied token is refused from any other key.

Kernel admits Workers from the DHT topic the same way: only 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; it is for throwaway local experiments only and logs a warning. Without it, a peer with the topic could register as a Worker and receive the commands, including credentials, for its device family.

Workers are fail-closed too. Give each Worker the Kernel's client key (kernel.getClientPublicKey(), not the listener key) as its only allowedKeys entry, so every command goes through Kernel's checks and write-command log. Do not add the Gateway's key: an app reaches devices through Kernel, never by dialing a Worker.

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 answers only the keys in its allowedKeys, and mdk run and the @tetherto/mdk-core helpers admit the Kernel's client key and no other. A Worker therefore takes device calls only through Kernel, where spec.rbac and the write-command log apply. The device network is still yours to isolate: a Worker's key protects the Worker, not the miner behind it. Security boundaries give the trust-boundary rationale behind the controls below.

Options

OptionWhen to pick itTrade-off
Workers only on the backend network, allowedKeys holding the Kernel client keyEvery 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)
allowAll on WorkerRuntime (MDK_WORKER_ALLOW_ALL=1)Throwaway local experiments onlyAny peer that learns the Worker key reaches devices with no policy check and no log entry. mdk run never sets it

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 and params 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: Write the policy and put identity in the Gateway

spec.rbac in mdk.yaml is the only place policy is written. The Kernel applies it at every start and on mdk rbac apply; a policy that fails validation is refused and the one in force stays. A rule names resources, verbs and, optionally, resourceNames; a binding ties a role to User, Group or ServiceAccount subjects. The RBAC vocabulary lists every resource, including devices (named family/brand/worker/device), actions, commands, policy, identities and app routes (<app>/<path> with get, list, create, update, delete).

A package never decides who may call it. An app only requests its own Kernel access in mdk-app.yaml (permissions, required); mdk add app, mdk create app, mdk onboard and mdk rbac init write that request as role app-<name> bound to service account app:<name>, and the role is yours to narrow from then on. mdk validate app <name> fails when the grant is missing, differs from the request or binds nobody. Who may call the app's routes is set by your own roles.

The Gateway is the user identity seam. It serves POST /auth/login, POST /auth/token, POST /auth/refresh, POST /auth/logout and GET /auth/me from the Kernel's token issuer. A client that keeps no cookies, such as the CLI or a script, sends "refreshIn": "body" at login and gets its refresh token in the body instead, which the Gateway's sign-in routes cover along with the rest of the cookie behavior.

On every app route it checks a bearer access token and a spec.rbac rule for the route's resource and verb (401 ERR_AUTH_UNAUTHORIZED, 403 ERR_AUTH_FORBIDDEN) before body validation, the cache or the app. A route that declares resourceName (from a param, query or body field) is checked for that instance. The app receives the verified identity, { kind, sub, email?, groups? }, as req._info, never the token. The Gateway apps guide covers the route fields.

Options

OptionWhen to pick itTrade-off
mdk rbac initEvery stack, and after mdk add appBinds mdk-cli to admin, mdk-gateway and mdk-mcp to gateway (policy: read), and writes each app's grant. A role you edited is left alone
mdk user add <name> --adminThe first operatorWrites the admin role if missing and binds the user. Keep the number of admins small; admin holds every wildcard
Narrow roles bound to Group subjects (mdk user add --group)Teams with different duties (read-only staff, one rack's technicians)More roles to review. resourceNames such as rack-3-* keep a technician to one rack
A custom authVerifier with no authorityA Gateway started with neither a Kernel nor a credentialNot checked against spec.rbac, and refused in mdk.yaml (spec.gateway.config.authVerifier). Outside the supported path once a stack has a policy

resourceNames bounds which devices an identity may address, not whose data it may see. A stat-* aggregate log is shared: a read is authorized against the device it is addressed to and returns the stored series without narrowing it to that device, so an identity scoped to one device can read a total that other devices contributed to. A tag the addressed device does not carry is still refused (ERR_LOG_TAG_FORBIDDEN), and the other devices stay unaddressable. Where tenants must not see each other's figures, separate them by Worker so they share no aggregate, rather than by resourceNames alone.

Suggested default: run mdk rbac init before the first mdk run (a spec.rbac that binds no user and no group, and whose bound subjects include nobody who may write the policy, makes mdk run refuse to boot the Kernel, which could not be handed a policy afterwards), create the first operator with mdk user add <name> --admin, then give everyone else a narrow role bound by group. mdk user add needs a running Kernel, so the order is mdk rbac init, then mdk run kernel, then mdk user add. Read the effective policy with mdk rbac show after every change, and keep mdk.yaml in review like code. Single sign-on federation is not built in: users and passwords live in the Kernel's identity store.

spec:
  rbac:
    enforcement: deny
    roles:
      - name: viewer
        rules:
          - resources: ["*"]
            verbs: [get, list]
      - name: rack3-tech
        rules:
          - resources: [ops-center/miners/command]
            verbs: [create]
            resourceNames: ["rack-3-*"]
    bindings:
      - role: viewer
        subjects: [{ kind: Group, name: ops-team }]
      - role: rack3-tech
        subjects: [{ kind: User, name: tech@example.com }]

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.

Also, on the Gateway:

  • Terminate TLS in front of it. The refresh cookie is Secure everywhere but plain-HTTP loopback, so over plain HTTP on a network the browser keeps no refresh cookie and every reload signs the user out
  • Set trustProxy only behind a TLS proxy whose hop count you trust: it makes the Gateway see https, and without a trusted proxy client IPs (and so rate limits) can be spoofed
  • List the names clients use in allowedHosts; only loopback on the bound port passes the Host check by default
  • Tune rateLimit (default 300 requests per minute per IP). It is counted before the auth check, so failed sign-ins are throttled too
  • Ship the Gateway's authz decision lines (subject, route, resource, verb, decision, policy version) to your log store
  • 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

Step 5: Authorize writes

Kernel checks every write against spec.rbac, whichever path it takes. A command.request needs devices: write on its target device, and a command scoped to a worker or rack needs it on every device that worker owns. Every attempt, refused ones included, is recorded in the write-command log with the caller's subject and the roles spec.rbac binds to it; reading it with command.log needs commands: read. When an app acts for a signed-in user (req.mdk), Kernel verifies the user's own token, requires both the app's and the user's rules to allow the write, and records the user as the caller via the app.

Options

OptionWhen to pick itTrade-off
Direct command (command.request)Low-impact, operator-in-front actions (LED, a single-device reboot)Fast. One identity's devices: write rule is the only gate
Approval-gated write (action.push, then votes)Fleet-changing actions: power mode, pool assignment, container PDU, bulk rebootSlower. A push needs actions: write and devices: write on every device it resolves to. A vote is checked against the stored action's target devices with the voter's own devices: write rule. 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

For a caller with a token, the voter and requester are the operator it acts for (req.mdk), else the caller's own identity, and the payload's self-asserted authPerms are ignored on both push and vote; they apply only to in-process calls. Naming another voter without the operator's token needs actions: delegate. Prefer req.mdk, which needs no delegate grant, and grant delegate only to an app whose code you trust to name the right user.

Provisioning a device

Registering, updating or forgetting a device is a write action (registerThing, updateThing, forgetThings) pushed through the Kernel with the target Worker in params[0].workerId. It needs actions: write to push, and devices: write on every device the push resolves to.

The payload requires voter and authPerms (ERR_PAYLOAD_VOTER_REQUIRED, ERR_PAYLOAD_AUTH_PERMS_REQUIRED), so a push without them is refused before its targets are resolved. For a caller carrying a token the Kernel then ignores what they assert and evaluates the caller's own identity and rules instead, so they satisfy the schema rather than grant anything.

updateThing and forgetThings name a device the registry already knows, so a devices: write rule narrowed with resourceNames matches them normally. registerThing does not: the device is not in the registry yet, so it resolves to no instance name, and a rule scoped to instances never matches a request that names none. Registering needs an unscoped devices: write rule, which grants write access to every device the policy covers. Bind that to a dedicated provisioning account rather than to an operator role, and keep the scoped rule for everything else.

A push that resolves and executes means the configuration is persisted. The running Worker does not publish the new device: it appears after the Worker restarts against the same store.

Suggested default: approval-gated writes for anything that changes hash, power, or network membership, with the people who vote bound to a role holding actions: vote and devices: write on the affected devices. Direct commands only where a single operator's devices: write rule is enough. Keep commands: read with the people who audit.

Step 6: Wire the UI session

passwordAuth() from @tetherto/mdk-ui-foundation signs a user in through the Gateway's /auth/login. The access token stays in memory; the refresh token stays in an HttpOnly, SameSite=Strict cookie scoped to /auth, out of reach of page scripts. The provider renews the token on a timer and on a 401, and tabs refresh one at a time under a Web Locks lock. Refresh tokens rotate on every use, and a used one presented again revokes the whole session (theft detection). The one exception is two clients refreshing at once: within about 10 seconds of its use, while its successor is still unused, a used token gets that same successor, so both hold one refresh token and the next reuse of it, by whoever is behind, revokes the session.

Options

OptionWhen to pick itTrade-off
passwordAuth({ baseUrl })Every dashboard on an MDK stackNeeds TLS on a network, or the browser keeps no refresh cookie. You own the sign-in page
bearerTokenAuth()A client that already holds an access token (a script, a kiosk handed a token)No refresh; the session ends on 401
noAuth()Fixture-backed demosNo token. Every app route on a real stack answers 401
Custom AuthProviderA session model the presets do not coverYou implement getToken, refresh, and sign-out

Suggested default for a production dashboard:

  • passwordAuth({ baseUrl: httpsOrigin }) passed to <MdkProvider>, with restore() before the first render
  • HTTPS end to end, a real apiBaseUrl, no Vite dev proxy
  • One fetch client (the provider's fetcher) so pages cannot skip Authorization
  • Route guards on any view that can mutate the fleet (the shell template uses <RequireAuth>)
  • Hide write controls the user's roles do not allow. Hiding is not authorization; the Gateway and Kernel still deny
  • Static bundle behind the same proxy that terminates TLS. Do not run vite dev as the production UI

Step 7: (Optional) MCP and the operator agent

MCP is optional. The standalone @tetherto/mdk-mcp process binds 127.0.0.1 and answers POST /mcp. With an authority (an in-process Kernel, or mdk run mcp signing in as mdk-mcp), every request needs an agent's own access token, each agent's tools/list is filtered by its rules, and each call is checked as the Gateway checks the same route. A tool scoped by an instance name is listed when some instance is allowed and checked per call; a tool with no declared access is not offered. The static spec.mcp.token and spec.mcp.identity are refused.

Options

OptionWhen to pick itTrade-off
No MCP, no agentDashboards and scripts onlySmallest surface
One service account per agent on loopbackAny agentmdk sa add <agent>, a binding in spec.rbac, then mdk sa token <agent> (or ServiceAccountTokenSource) for its bearer token. The agent reaches apps as itself
Operator agent app on the GatewayIn-process chat for people who already signed inKeep approvalTimeoutMs on. Chat sessions bind to the signed-in user's sub, so one operator cannot read another's
MCP published past localhostAn agent runtime on another hostPut TLS in front and add the name to allowedHosts. The token check still applies; the transport is yours

Suggested default: leave MCP on 127.0.0.1, give each agent its own service account with a read-only role, and grant write tools only with allowWrite or allowTools plus a rule that allows them. 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 on the Gateway HTTP port onlyInsufficientThe Gateway keeps serving HTTP while the Kernel is unreachable; Kernel-backed calls fail with ERR_MDK_CLIENT_UNAVAILABLE until it is back.

Suggested default:

  • Mode 0700 (or equivalent) on the Kernel db directory, .mdk/, the trusted-keys dir and Worker storeDir, and 0600 on seed, key and credential files. The Kernel store holds users, password hashes and the token signing key; .mdk/credentials/ holds every app's and agent's private key. Encrypt at rest if the host requires it
  • Back up those directories as operational data, with the same access control as the live files
  • When staff leave, mdk user disable or mdk user remove them: their access tokens stop at the Kernel at once, and their bindings stay until you remove them from spec.rbac
  • Rotate service-account keys with mdk sa rotate, and Kernel, Gateway and Worker transport keys when a host is rebuilt, updating the allowlist in the same change
  • Practice recovery from a wiped data directory before an incident
  • Watch Gateway-to-Kernel errors and the Gateway's policy version, 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 an admission in running processes. Use kernel.revokeKey(key) and each affected runtime.revokeKey(key), and remove the publication and startup configuration that would admit it again. Check the live Kernel and Worker allowlists, not just files on disk, and verify that refused calls fail after revocation and after restart.

Trust boundaries and known limits

These hold by design. Plan the site around them.

Boundary or limitWhat happensWhat to do
App routes are a confused-deputy boundaryThe Gateway checks the user against the route's rule and forwards their token. A controller calling Kernel through req.mdk acts for that user: Kernel checks both app:<name> and the user. A controller calling through config.mdkClient acts as the app alone, and Kernel does not re-check the userUse req.mdk for work done for a request, keep each app's grant narrow (resourceNames), and review app controller code like any privileged service
Policy at a remote GatewayA Gateway or MCP server in its own process polls the policy every 5 seconds. A failed poll keeps the last good policy, so a Kernel blip does not lock the site, but for 60 seconds at most (maxStaleMs): past that it allows nothing until a poll succeeds. Kernel switches at onceWatch for failing polls; run the Gateway in the Kernel's process where an instant switch matters
Revoked access tokens at the edgeA password change, disable, delete, group or email change, or new service-account key refuses the subject's tokens at once wherever the issuer verifies: every Kernel request, and an in-process Gateway or MCP server. A Gateway or MCP server in another process polls every subject's token version with the policy, so there a revoked token keeps working until the next poll (up to 5 seconds); Kernel refuses it at onceTreat the edge window as one poll interval. Kernel-backed calls stop immediately
Login lockout is in memoryFailed sign-ins lock a user name (5 failures, doubling from 30 seconds up to 15 minutes), counted in the Kernel's memory; a Kernel restart clears itRate-limit /auth/login at the proxy too, and alert on repeated failures in the authz log
Service-account assertionsAn assertion lives at most 60 seconds and is single use, remembered in the store across restartsKeep agent and app private keys on the host that owns them

Decision cheat sheet

DecisionSuggested production choiceDemo-only choice
TopologyMicroservices over DHT, three networks (operator / backend / devices)Single process on loopback
Kernel admissionAllowlist of persistent Gateway, MCP and app runtime keys; trusted-keys dir on one machineIn process through mdk run
Worker accessBackend network only, allowedKeys holding only the Kernel client keyallowAll on a throwaway local Worker
Policyspec.rbac from mdk rbac init, narrow roles bound by group, reviewed like codeOne admin binding
User identitymdk user add --admin for the first operator, Gateway /auth/*, TLSPlain HTTP on loopback
WritesApproval-gated for fleet changes; voters hold devices: write on the targetsDirect command.request as an admin
UIHTTPS, passwordAuth(), route guardsVite proxy, noAuth() on fixtures
MCP / agentLoopback, one service account per agent, read-only role, human approvalDisabled
SecretsSecret manager into the process, .mdk/ and stores 0700Example admin passwords

Next steps

On this page