Skip to content

Security

NimbleBrain exposes an HTTP API that controls an agent with tool execution capabilities. A misconfigured deployment can give unauthorized users shell access to your server. This page covers every security surface you should address before exposing NimbleBrain to a network.

NimbleBrain uses pluggable authentication adapters configured via instance.json in the work directory. The available adapters are:

Adapter Use case Configuration
dev Local development adapter: "dev": every request is one built-in owner, no login
oidc Enterprise / SSO Authorization-code flow against any OIDC provider
workos Enterprise / SSO via WorkOS AuthKit-hosted login (adapter: "workos")

The adapter is selected by the auth.adapter field in instance.json. The server does not start without the file: a missing identity config never selects an adapter.

For the OIDC and WorkOS adapters, sign-in is an authorization-code flow, not a login form that the platform posts credentials to. There is no POST /v1/auth/login endpoint. The browser flow is:

  1. The web client redirects to GET /v1/auth/authorize, which sends the user to the configured identity provider.
  2. The provider authenticates the user and redirects back to GET /v1/auth/callback with an authorization code.
  3. The callback exchanges the code for tokens and sets the nb_session cookie (see Session cookies below). It does not validate a shared secret — the cookie carries the provider-issued access token.
  4. Subsequent requests present either the nb_session cookie or an Authorization: Bearer <token> header. Programmatic callers (CLI, external MCP clients) use the bearer header; the browser uses the cookie.

POST /v1/auth/refresh rotates an expiring session, and POST /v1/auth/logout clears it. These four routes (authorize, callback, refresh, logout) are the entire auth surface — there is no login route and no NB_API_KEY.

Every failed authentication attempt is logged to stderr with the client IP in a structured field:

[auth] authentication failed { ip: "203.0.113.42" }

The IP comes from the X-Forwarded-For header (set by your reverse proxy) or "direct" for direct connections. Monitor these logs for brute-force attempts.

The OIDC/WorkOS callback (GET /v1/auth/callback) sets the nb_session cookie after a successful authorization-code exchange. The web UI uses this cookie for all subsequent same-origin requests. No login endpoint is involved — the cookie value is the provider-issued access token, not a platform-minted secret.

Attribute Value Purpose
Name nb_session Identifies the session
HttpOnly Always set Prevents JavaScript access — mitigates XSS token theft
SameSite Lax Cookie sent on same-site requests and top-level navigations — mitigates CSRF
Secure Set when not localhost Cookie only sent over HTTPS
Path / Available to all routes
Max-Age 3600 (1 hour) Session expires after one hour; refreshed via POST /v1/auth/refresh

The Secure flag is automatically omitted for localhost connections (where http:// is expected) and added for all other hosts.

POST /v1/auth/logout clears nb_session and nb_refresh by setting Max-Age=0. It needs no valid session, so sign-out still works after the access token has expired. It does require Content-Type: application/json: that header forces a CORS preflight, which a foreign origin fails, so another site cannot sign a user out with a plain form post. The client should discard any cached auth state.

Cross-Origin Resource Sharing controls which domains can make API requests from a browser. The policy is the same for every auth adapter, dev included; ALLOWED_ORIGINS decides it:

When ALLOWED_ORIGINS is not set:

  • No Access-Control-Allow-Origin header is sent
  • Only same-origin requests work (browser enforces this)
  • Cross-origin requests from any domain are blocked

When it is set:

  • Access-Control-Allow-Origin is set to the requesting origin only if it appears in the allow list
  • Access-Control-Allow-Credentials: true enables cookie-based auth
  • Vary: Origin ensures caches differentiate by origin
Terminal window
export ALLOWED_ORIGINS=https://nb.example.com,https://admin.example.com

These headers are always permitted in CORS requests:

Content-Type, Authorization, Last-Event-ID, Mcp-Protocol-Version, Mcp-Method, Mcp-Name

These headers are exposed to client JavaScript:

Mcp-Protocol-Version

CORS stops a script on another origin from reading a response, but a plain form or text/plain POST needs no preflight, and SameSite=Lax sends the session cookie on it when the other origin is on the same site. So any write (a method other than GET, HEAD or OPTIONS, on any route: /v1/*, /mcp/<wsId>, webhook deliveries) that the browser marks Sec-Fetch-Site: same-site or cross-site is refused with 403 cross_site_request, unless its Origin is in ALLOWED_ORIGINS. Same-origin requests, and clients that are not browsers (MCP clients, webhook senders, services calling the API), send no such header and are unaffected. A browser request that CORS admits after a preflight comes from an allowed origin, so it is unaffected too.

The platform service (port 27247) should never be directly exposed to the internet. The platform runs on an internal Docker network:

  • The platform has no ports mapping. Only the web container (Caddy) is exposed on port 27246. Caddy proxies /v1/* to the platform.
Internet → Reverse Proxy (TLS) → Web (27246) → Platform (27247, internal)
↑
Serves UI + proxies /v1/*

If your host is directly on the internet, restrict inbound traffic:

Terminal window
# Allow only HTTPS (443) and SSH (22)
ufw allow 22/tcp
ufw allow 443/tcp
ufw deny 27246/tcp # Block direct access if behind a reverse proxy
ufw enable

NimbleBrain does not terminate TLS itself. Use a reverse proxy for HTTPS:

nginx.conf
server {
listen 443 ssl http2;
server_name nb.example.com;
ssl_certificate /etc/ssl/certs/nb.example.com.pem;
ssl_certificate_key /etc/ssl/private/nb.example.com-key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# HSTS — only enable once you confirm TLS works
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
location / {
proxy_pass http://127.0.0.1:27246;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for SSE streaming
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
}
}

Each workspace’s MCP endpoint, /mcp/<workspaceId>, is its own OAuth protected resource. The resource its discovery document advertises, and the audience a token must carry to be accepted there, is built from the instance’s configured public origin (NB_PUBLIC_ORIGIN, or the custom domain / platform host the deployment forwards) — never from the request’s Host or X-Forwarded-* headers. A proxy in front of the instance therefore cannot change it, and a caller cannot choose the audience a token is checked against. See The MCP Endpoint for the rule.

Two things must be true for external MCP clients to connect:

  • The public origin is the host clients use. A client asks for a token for the URL it was given; if that URL’s host differs from the configured public origin, the token’s audience will not match and every request is refused. Give users the URL from Workspace settings → MCP, which is always canonical.
  • The authorization server has a resource indicator for each public host. Register one covering <origin>/mcp/* for the platform host and for every custom domain. Without it, the authorization server ignores the client’s resource, mints a token whose audience is the client id, and every MCP request is refused with 401.
Terminal window
curl -s https://nb.example.com/.well-known/oauth-protected-resource/mcp/<workspaceId> | jq .resource
# must be "https://nb.example.com/mcp/<workspaceId>"

The runtime executes no connector code. A connector is a remote MCP server the platform connects to over HTTP, so nothing an operator installs runs inside the platform container, shares its filesystem, or sees its environment. That moves the supply-chain question to where it belongs: what the server’s operator built and published, checked before the server is deployed, not by a process that also holds tenant credentials.

What is left to evaluate at install time is the connection:

  • Who runs the endpoint. Install from a curated catalog you control (Connectors Catalog) rather than pasting arbitrary URLs; use connectorsAllowList to narrow what a given workspace may install at all.
  • What it can reach. Connector URLs are checked against the SSRF allowlist — loopback, RFC1918, and cloud-metadata hosts are refused unless allowInsecureRemotes is on. Leave it off outside development.
  • What it is granted. OAuth scopes are declared on the catalog entry and shown on the consent screen. Credentials are workspace-scoped: a connector authorized in one workspace is not reachable from another.
  • What its tools may do. The per-connector permission table (Configure → Tools) is the place to disallow a tool the workspace should not expose to the agent.

Every tool call reaches a tool through one of three doors, and all three run the same gates. What differs is only where the caller’s identity comes from.

Door Who is calling Where the workspace comes from
Chat and task runs The authenticated user, captured when the session opened The conversation’s own workspace, or the run’s provenance workspace
/mcp/<workspaceId> The authenticated client’s user The URL, validated against that user’s membership on every request
Unattended dispatch A user id stored in workspace configuration by an admin The workspace that configuration belongs to

The third is the one worth understanding, because it is the only one where nobody is present. It makes exactly one tool call — no model, no conversation, no follow-up — on behalf of a principal that a workspace admin’s write recorded earlier. The principal is never taken from a request: there is no header, no body field, and no “act as” parameter that can name a different user, so the door cannot be pointed at someone by an incoming call.

Before that call runs:

  • The principal must be a current member of the workspace. A user who has since been removed does not get a denial — the call is skipped and recorded as owner_not_member, and it starts working again by itself if they are added back. This is the same rule that governs a scheduled task whose owner leaves.
  • The tool must not widen the caller’s own reach. Creating or modifying tasks, authoring skills, and installing or disconnecting connectors are all refused. A new task fires again later as the same principal, a new skill loads itself into their later sessions, and a new connector adds tools and credentials that were not reachable before — so each is a way an unattended call could come back with more than it started with. Writing a workspace’s custom instructions is refused for the same reason, by the same rule that already refuses it inside a scheduled task run. Ordinary writes — sending a message, updating a record — are not refused; that is what the door is for.
  • The per-connector permission table applies, exactly as it does in chat: a tool set to disallow is refused here too.
  • A personal connector needs its owner’s grant to the workspace. The principal is a person, and the call runs on their credentials, so it gets no exemption for being made by the platform.
  • The workspace wall applies. A call can only ever reach the one workspace the configuration named; no tool name can address another.

The call is bounded by a wall-clock timeout and a cap on the size of the result it may return, and it writes nothing — no conversation, no run record.

Every attempt is audited, whatever the outcome. The workspace event log (logs/workspace/<date>.jsonl under the work directory, or under logging.dir when you set one) gets one audit.unattended_dispatch line per call carrying the principal, the workspace, the tool, the caller’s own reason string, and the outcome. Since a dispatch leaves no transcript behind, this line is the record.

Everything a deployment keeps lives in its work directory (NB_WORK_DIR, /data in the container): conversations, files, workspace records, logs, and the credential store, which holds API keys, OAuth tokens and client secrets. Anyone who can read that directory, or any copy of it, can read all of it. Two layers protect it, and they cover different things.

Encrypt the volume. Put the work directory on storage that is encrypted at rest. That protects the disk, its snapshots, and the backups taken from them.

  • Docker: encrypt the host’s disk, or the device backing the volume.
  • Kubernetes: use a StorageClass that encrypts. On AWS EBS that is the class parameter encrypted: "true"; turning on EBS encryption by default for the account, in each region you use, also covers volumes created any other way.

A snapshot or backup has the encryption of the volume it was taken from. Backups of an unencrypted volume stay unencrypted for as long as they are retained, and encrypting the volume later does not change them.

A volume cannot be encrypted in place. To move an existing one: stop the workload, snapshot the volume, create an encrypted volume from the snapshot, create a PersistentVolume for it, re-create the claim bound to that PersistentVolume (a claim’s volumeName cannot be changed), and then delete the snapshot, which is an unencrypted copy of the data. Set the PersistentVolume’s reclaimPolicy to what the old one had. If a CSI driver manages the claim, give the new volume the tags the driver uses to recognize its own volumes, or it cannot delete the volume later.

Seal the credential store. Sealing encrypts each stored secret under a key held in the environment, so secrets stay protected where the disk is not, and in every copy of the work directory: a tar backup, a copied volume, the archive of a workspace deleted after sealing was on. See Sealing the files.

The boot sweep that seals existing secrets does not walk archived/, so a workspace deleted before sealing was on keeps its secrets in plaintext. Rotate those credentials at the vendor, then remove the archive with manage_workspaces purge_archive.

Neither layer protects against someone who can read the running process’s environment or open a shell in its container. That is an access-control boundary, not an encryption one.

Use this checklist before exposing NimbleBrain to any network:

Item How to verify
Authentication configured instance.json exists with auth.adapter set to oidc or workos
ALLOWED_ORIGINS set to your domain(s) curl -v with an Origin header and confirm the response includes Access-Control-Allow-Origin
Platform port (27247) not exposed docker compose ps shows no host port mapping for platform service
TLS enabled curl -I https://nb.example.com returns a valid certificate
X-Forwarded-For header set by proxy Check auth failure logs for real IPs, not "direct"
Connector list reviewed cat workspaces/<wsId>/workspace.json — only connectors you trust are installed
Firewall restricts inbound ports Only 443 (HTTPS) and 22 (SSH) reachable from the internet
Backups configured Test your volume backup and restore procedure
Work directory on an encrypted volume Your platform reports the volume encrypted (on AWS: aws ec2 describe-volumes --volume-ids <id> --query 'Volumes[].Encrypted')
Credential store sealed secrets.config.seal is set, and this prints nothing: find /data -path '*/credentials/secrets/*' -type f ! -name '.*' | while read -r f; do head -c5 "$f" | grep -q '^NBS1\.' || echo "$f"; done