DurinDoor
Operations

Security

JWT_SECRET, passwords, API_KEY_SECRET, MACHINE_ID_SALT, rate limits, and dashboard exposure.

DurinDoor stores provider credentials and proxies model traffic. Treat DATA_DIR as a secret store.

Do not publish the dashboard on a public address with the built-in password 123456. Remote login on that password returns 403 before a session cookie is issued. Dashboard protection does not replace inference API-key protection.

Secrets

DATA_DIR permissions

DATA_DIR (default ~/.9router, or the configured path) is created owner-only (0700), and the columnCrypto master key (DATA_DIR/master-key) is written 0600. Both are also repaired on next use if an older install left them group- or world-readable, so a permissive process umask cannot make credentials readable to other local users. Repair is best-effort and skipped on Windows, which has no POSIX mode bits.

JWT_SECRET

Signs dashboard session cookies (HS256, 24-hour exp and cookie Max-Age). Resolution order:

  1. process.env.JWT_SECRET
  2. Existing DATA_DIR/jwt-secret (legacy file, warning on boot)
  3. Throw. Fresh installs do not mint that file.

Changing the secret invalidates every dashboard session. Generate with openssl rand -hex 32.

A JWT_SECRET copied from .env.example or these docs (replace-me, your-secret, CHANGE_ME_LONG_RANDOM_SECRET, change-me and similar, case-insensitive) is ignored with a warning and treated as unset. Anyone who can read the docs could forge a session with it, so DurinDoor falls through to the legacy file or refuses to sign sessions.

INITIAL_PASSWORD

Used only when no password hash is stored. Default is 123456. validateDashboardPassword rejects that string as a new password (minimum 6 characters, not the built-in default).

Remote clients that still have the built-in password never get a session cookie. A loopback login gets a five-minute, IP-bound, one-shot proof that only POST /api/auth/change-password accepts. Set INITIAL_PASSWORD or store a hash before any remote access.

An INITIAL_PASSWORD copied from .env.example or these docs counts as the default too. change-me, changeme, replace-me, your-password, your-secure-password and CHANGE_ME_STRONG_PASSWORD (case-insensitive) are public, so remote login on them returns the same 403 and the local machine gets the change-password proof. validateDashboardPassword also rejects them as a new password. Pick a value of your own.

The login hint for the default password shows only when the effective password is still the built-in value. A stored custom hash or OIDC mode hides it.

API_KEY_SECRET

HMAC secret for four-part keys sk-<machineId>-<keyId>-<crc8>. Order:

  1. process.env.API_KEY_SECRET
  2. DATA_DIR/api-key-secret minted on first boot when DATA_DIR is set (mode 0600)
  3. Throw

Save the secret in your protected service environment and keep it stable across deploys. A new secret makes existing four-part keys fail parseApiKey. Two-part sk-<8 hex> rows are not HMAC-bound. Full key shapes: API keys.

MACHINE_ID_SALT

Salt for getConsistentMachineId. Default endpoint-proxy-salt. The 16-character id is embedded in new API keys and stored in DATA_DIR/machine-id. Changing the salt changes the derived id for new keys. Leave it alone on a running install.

Enable two-factor authentication

For password logins, turn on the optional second factor from the dashboard: Settings, Security tab, Two-factor authentication card. Enrollment requires the current password, then a valid 6-digit code from the new secret before it is stored.

Login becomes two steps once enabled: POST /api/auth/login returns { mfaRequired: true } and sets a short-lived, httpOnly mfa_pending cookie (5 minutes) instead of a session. POST /api/auth/mfa/verify trades a valid TOTP code, or a backup code, for the real auth_token session cookie. Both steps share the same login rate limiter (5 fails, then 30s/2m/10m/30m lockout) and require an exact request Origin, matching password login.

Ten single-use backup codes are generated at enrollment and shown once, bcrypt-hashed at rest. Each recovers the account if the authenticator device is lost; using one does not disable MFA, it only substitutes for a TOTP code on that one login. Losing the authenticator and every backup code means POST /api/auth/mfa/disable (which itself requires a live second factor) cannot run; recovery requires an administrator repair of the MFA settings on a verified database copy. Back up the database first. The CLI password reset clears only the password hash; it does not disable MFA or restore access without a second factor.

mfaSecret and mfaBackupCodes are stored in the same settings row as the password hash. They never round-trip through GET/PATCH /api/settings (only a boolean mfaEnabled and a mfaBackupCodesRemaining count are exposed there) and are writable only through /api/auth/mfa/{setup,enable,disable}, each of which re-verifies the current password.

Rate limits

Dashboard login: five failures from one IP, then a lock of 30s, 2m, 10m, 30m. A quiet hour since the last fail resets the bucket. At most 5000 IPs are tracked. HTTP 429 includes Retry-After.

Client IP for that limiter comes from custom-server.js peer proof (x-9r-real-ip). Without the wrapper, every caller shares the unknown bucket so spoofed X-Forwarded-For cannot rotate out of lockout. TRUST_PROXY=true is honoured only when that proof is present.

Provider 429 backoff (account/model lock after fallback) reads BACKOFF_BASE_MS (default 2000), BACKOFF_MAX_MS (default 300000, cap 7 days), and BACKOFF_MAX_LEVEL (default 15). A max below the base restores the whole default schedule.

Exposing the dashboard

custom-server.js binds 127.0.0.1 when HOSTNAME is unset. The CLI default bind is 0.0.0.0. Docker compose sets HOSTNAME: "0.0.0.0". Publishing that address is an operator choice.

Before anything other than loopback can reach /dashboard:

  1. Set a strong INITIAL_PASSWORD (or a stored hash).
  2. Set stable JWT_SECRET and API_KEY_SECRET.
  3. Terminate TLS in front. Set AUTH_COOKIE_SECURE=true or a https: BASE_URL.
  4. Restrict who can hit the dashboard: VPN, firewall, reverse-proxy auth, or a trusted network.
  5. Mint one DurinDoor API key per tool. Do not hand provider keys to clients.
  6. Enable Require API Key before exposing inference. A non-loopback bind without it logs a warning but does not block startup.
  7. Leave ENABLE_REQUEST_LOGS=false except for a short debug window.
  8. Back up DATA_DIR and the protected environment. For PostgreSQL, also back up the database.

AUTH_COOKIE_SECURE=true forces the Secure flag. TRUST_PROXY=true only when the reverse proxy overwrites forwarding headers and traffic goes through custom-server.js. Cross-origin login is rejected (hasExactRequestOrigin). Tunnel dashboard access also needs settings.tunnelDashboardAccess === true.

If the proxy rewrites Host to an internal name, set BASE_URL and NEXT_PUBLIC_BASE_URL to the public origin so Origin checks match.

Isolated web-provider login

Set DURINDOOR_WEB_LOGIN_ORIGIN=https://login.gateway.example for dashboard-assisted web-cookie sign-in, with the dashboard on a different hostname such as https://gateway.example. Route both hosts to the same custom-server.js deployment and terminate TLS. A different port on the dashboard hostname is not isolation: cookies are shared across ports. Missing, malformed, or same-hostname configuration disables assisted login; manual cookie entry remains available.

The isolated hostname accepts only /__web_login/bootstrap and /__web_login/<provider>/…. Before Next handles a request, the custom server denies dashboard pages, authentication and management APIs, static assets, local administration bridges, and all WebSocket upgrades on that hostname, regardless of credentials. Keep the original Host header at the reverse proxy; forwarded host headers are not trusted.

Provider response headers are untrusted too. The login proxy removes Next routing, continuation, request-override and middleware-cookie directives before Next consumes the response. An upstream page cannot use these headers to bypass the isolated-host boundary or turn a provider response into an application or arbitrary-host dispatch; ordinary provider redirects still use rewritten Location headers.

Bootstrap grants are short-lived and single-use. The login cookie is host-only and HttpOnly; do not configure Domain-wide dashboard cookies. Login pages run cross-origin to the dashboard, and iframe/popup access never falls back to the dashboard origin. Management mutations and protected reads with a foreign Origin cannot use dashboard cookies or an open-dashboard bypass; valid explicit CLI/API credentials retain their existing permissions, including the stricter raw-secret policy.

Management routes

GET /api/headroom/status and GET /api/headroom/stats use the management API policy because they expose the configured proxy URL, managed process and circuit state, and usage statistics. Remote callers need a dashboard JWT, a machine-bound CLI token, or a DurinDoor application API key, including when requireLogin is false. Direct loopback requests keep open-dashboard access when login is disabled.

Only the exact OIDC /api/auth/oidc/start and /api/auth/oidc/callback login endpoints are public. POST /api/auth/oidc/test is operator-only because its probe can use the stored client secret; both middleware and the handler reject foreign-Origin cookie requests before discovery or probing. Explicit machine-bound CLI credentials remain supported.

The management control endpoint POST /api/mcp/control is exempt from the loopback-only MCP plugin branch. It requires a CLI token, API key, or dashboard JWT, including from a remote host. A credential-free loopback caller is accepted only while requireApiKey is off and only when the request carries no foreign Origin, so a browser page cannot drive it cross-origin. The MCP gateway (/api/mcp-gateway) uses gateway keys, not that local-only policy.

Covered route families and exclusions: Management API. Control tools: MCP control.

API keys and scoping

Create separate keys, short expiry when you can, revoke unused ones. The create response is the one body that always includes the full secret. Scope a key to named provider accounts: API key scoping.

If a secret leaks

DurinDoor API key: revoke it in the dashboard, mint a replacement, update the client, read usage.

Provider credential: revoke it at the provider, update the DurinDoor connection, check the provider's billing log.

On this page

Edit on GitHub