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.
Authentication
Section titled “Authentication”Auth adapters
Section titled “Auth adapters”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.
How authentication works
Section titled “How authentication works”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:
- The web client redirects to
GET /v1/auth/authorize, which sends the user to the configured identity provider. - The provider authenticates the user and redirects back to
GET /v1/auth/callbackwith an authorization code. - The callback exchanges the code for tokens and sets the
nb_sessioncookie (see Session cookies below). It does not validate a shared secret — the cookie carries the provider-issued access token. - Subsequent requests present either the
nb_sessioncookie or anAuthorization: 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.
Failed authentication logging
Section titled “Failed authentication logging”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.
Session cookies
Section titled “Session cookies”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.
Cookie attributes
Section titled “Cookie attributes”| 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.
Logout
Section titled “Logout”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:
ALLOWED_ORIGINS unset
Section titled “ALLOWED_ORIGINS unset”When ALLOWED_ORIGINS is not set:
- No
Access-Control-Allow-Originheader is sent - Only same-origin requests work (browser enforces this)
- Cross-origin requests from any domain are blocked
ALLOWED_ORIGINS set (production)
Section titled “ALLOWED_ORIGINS set (production)”When it is set:
Access-Control-Allow-Originis set to the requesting origin only if it appears in the allow listAccess-Control-Allow-Credentials: trueenables cookie-based authVary: Originensures caches differentiate by origin
export ALLOWED_ORIGINS=https://nb.example.com,https://admin.example.comAllowed headers
Section titled “Allowed headers”These headers are always permitted in CORS requests:
Content-Type, Authorization, Last-Event-ID, Mcp-Protocol-Version, Mcp-Method, Mcp-NameThese headers are exposed to client JavaScript:
Mcp-Protocol-VersionCross-origin writes
Section titled “Cross-origin writes”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.
Network architecture
Section titled “Network architecture”Keep the platform internal
Section titled “Keep the platform internal”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
portsmapping. Only thewebcontainer (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/*Firewall rules
Section titled “Firewall rules”If your host is directly on the internet, restrict inbound traffic:
# Allow only HTTPS (443) and SSH (22)ufw allow 22/tcpufw allow 443/tcpufw deny 27246/tcp # Block direct access if behind a reverse proxyufw enableNimbleBrain does not terminate TLS itself. Use a reverse proxy for HTTPS:
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; }}nb.example.com { reverse_proxy 127.0.0.1:27246}Caddy handles TLS automatically via Let’s Encrypt.
MCP OAuth: the resource URL
Section titled “MCP OAuth: the resource URL”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’sresource, mints a token whose audience is the client id, and every MCP request is refused with401.
Verify
Section titled “Verify”curl -s https://nb.example.com/.well-known/oauth-protected-resource/mcp/<workspaceId> | jq .resource# must be "https://nb.example.com/mcp/<workspaceId>"Connector trust
Section titled “Connector trust”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
connectorsAllowListto 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
allowInsecureRemotesis 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.
How a tool call gets authorized
Section titled “How a tool call gets authorized”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.
Data at rest
Section titled “Data at rest”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.
Production checklist
Section titled “Production checklist”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 |