Security model
How a Hubzoid deployment is protected, covering network exposure, sign-in, fail-closed authorization, secrets, MCP keys, data locations and egress.
A Hubzoid deployment has one public port, verifies every person through the chat app's sign-in, and decides every restricted tool call in code before the tool runs. Every decision is recorded, and a decision that cannot be recorded does not run. This page describes each boundary as the software implements it, and what remains yours to operate.
When to use this
Read this before exposing a deployment beyond a private network, when preparing a security review, and when deciding which surfaces, tools and backends a hub may use. For how to report a vulnerability, see the end of this page.
- TLS proxy
- Edge
- Chat app sign-in
- Bridge key
- Agent entry
- Tool permission
- Decision log
Network exposure
| Component | Binds to | Reachable from |
|---|---|---|
| Edge | --host (default 127.0.0.1) on PORT (default 3080) | Your reverse proxy, which terminates TLS |
| Chat app | 127.0.0.1, internal port | The edge only |
| Bridge | 127.0.0.1 on BRIDGE_PORT | The same machine only |
| Inbound server | 127.0.0.1 on HUBZOID_INBOUND_PORT | The edge only |
The edge forwards a short list of paths to a bridge and everything else to the chat app:
| Public path | Destination | Protected by |
|---|---|---|
/ and the chat app's API and websockets | Chat app | Chat app sign-in |
/portal/ | Bridge (the Console and the artifact viewer) | Chat app session validated on the server, or a personal API key on /portal/api. Admin screens need manage_access. An artifact opens for the people its owner shared it with, or through an expiring public link under /portal/p/. |
/artifacts/... (in a gateway, /b/<slug>/artifacts/...) | Bridge | A per-hub signed link, or a bridge key that is not dev |
/mcp (in a gateway, /b/<slug>/mcp) | Bridge, only when MCP_SERVER=true | A per-user API key |
/webhooks/<slug>/<surface> | Inbound server, only when configured | Signature, shared secret or HMAC on every request |
The bridge's /v1 chat API, /uploads and /healthz are never forwarded, because exposing /v1 would hand the model to anyone holding a bridge key and bypass the chat app's sign-in. The edge also:
- refuses any path with a
.or..segment, which could otherwise reach bridge paths outside the forwarded prefixes - drops every client-sent
X-Hubzoid-*andX-OpenWebUI-*header before forwarding - keeps no sign-in cookie between visitors. Releases before 1.0.1 could pass one visitor's session to another, so rotate
WEBUI_SECRET_KEYafter upgrading from one. - relays websockets to the chat app only
hubzoid doctor warns (exposure.bind) when the public port listens on every interface, and fails auth.chat_signin when it does so with sign-in off.
Authentication
Each credential is accepted in one place and proves one thing. None of them stands in for another.
Chat app session
WEBUI_SECRET_KEYIssued when a person signs in.
Who the person is
Web chat and the Console
Console admin screens also require
manage_access.Personal API key
Minted by a person in the chat app.
That person, to an assistant or a script
MCP and the management API
/mcp/portal/apiIt opens no chat app endpoint. The management API also requires
manage_access.Connected account token
From a person signing in to a per-user MCP server.
The agent may act as them there
That MCP server
On that person's turns only.
Bridge key
BRIDGE_API_KEYSHeld by the chat app, Slack adapter and inbound server.
A trusted Hubzoid process
The bridge, on loopback
/v1Never forwarded by the edge, and refused on
/mcp.Channel secrets
WhatsApp signature, Telegram secret token, webhook secret or HMAC.
The delivery is genuine
The inbound server
/webhooks/<slug>/<surface>Provider and tool credentials
.envrestricted/.envLoaded by the Hub at start.
The Hub itself
Your model provider and systems
The agent's file tools cannot read
restricted/.
People. The chat app (Open WebUI) owns accounts and sessions. Sessions are signed with WEBUI_SECRET_KEY, and Hubzoid refuses to start with sign-in on and no key, or with Open WebUI's public fallback key. See authentication for every sign-in method.
The Console. It accepts a session issued by the chat app, validated on the server, and on its management API (/portal/api) also a person's own API key (Bearer sk-...), checked against the chat app's key table as on /mcp. It ignores identity headers sent by a browser, requires writes made with a session to come from the same origin, and admits only people with manage_access, organization-wide or in an agent. A development bypass exists (HUBZOID_PORTAL_DEV with HUBZOID_PORTAL_DEV_USER) and must never be set on a shared or exposed deployment.
Bridges. The chat app, the Slack adapter and the inbound server call a bridge with a key from BRIDGE_API_KEYS. Identity headers are trusted only on requests that carry that key, and end users never talk to the bridge. The default key dev is public: it is refused on download links, the bridge logs a warning when it is configured, and hubzoid doctor fails auth.bridge_keys when it is in use or no key is set.
MCP clients. A person mints a personal API key in the chat app and presents it as a bearer token on /mcp. The bridge resolves the key read-only against the chat app's database, compares keys in constant time, and refuses expired keys, pending accounts and blocked people. Bridge keys are never accepted on this surface. Inside the chat app, those API keys are restricted to no endpoints at all, so they work only as an MCP credential and on the Console's management API. For a hub on managed grants, the MCP front door is the person's use_hub grant. See connect an assistant.
Connected accounts. With per-user MCP servers, a person signs in to the service once. The chat app stores their token encrypted with WEBUI_SECRET_KEY, and the bridge uses it only on that person's turns, and only on surfaces allowed to reach restricted tools, so a shared Slack channel mention never carries the mentioner's token. On a hub managed in the Console, each app also needs the person's connector_<app> grant. On claude-local, connection tokens and a hub server's headers and env go in a per-turn file readable only by Hubzoid's account, never on a process command line. See tools and connectors.
Inbound channels. WhatsApp requests must carry a valid X-Hub-Signature-256 HMAC of the raw body. Telegram requests must carry the configured secret token. Generic webhooks require the shared secret, compared in constant time, or an HMAC signature. A WhatsApp or Telegram sender who is not in the hub's roster gets a fixed reply and never reaches the model, the tools or any data. See webhooks.
Download links. Links to files the agent made carry an HMAC over the chat and file name, keyed with the hub's own secret in .hubzoid/artifact_secret (created with mode 0600), never with the bridge key. HUBZOID_ARTIFACT_LINK_TTL makes new links expire. Deleting the secret revokes every issued link.
Authorization
Agent entry. For a hub on managed grants, every chat, API and MCP request needs the verified person's use_hub grant. The subject is taken only from the identity the chat app forwards, never from the caller-supplied user field of an API request. Access goes to named people. New access for everyone signed in is refused everywhere, and a carried-over Everyone signed in row works only until an organization administrator removes it. The chat app's agent picker applies the same entry decision to every signed-in person, including chat app administrators. A blocked person is refused on every surface.
Restricted tools. A tool placed in the hub's restricted/ folder requires the permission named after its file. Two checks run, in order:
- The surface gate. The request's surface must be allowed to reach restricted tools at all. The default list is
owui,web,api,mcpandworkflow, all of which carry one verified account: the signed-in person, or the account a workflow run acts as. Slack, WhatsApp and Telegram are not in it.HUBZOID_RESTRICTED_SURFACESreplaces the list.slack-dmis safe to add, andslack-channelmust never be added, because a shared channel thread is answered under one person's identity. - The permission. A managed grant for that account, or, on a hub that still uses group-based access, membership of a chat app group with the permission's name.
On every runtime, tools the caller may not use are hidden from the agent, and the tool re-checks at call time. That call-time check is the boundary: it holds even if a prompt names the tool directly.
Fail closed. Once a hub is on managed grants, any error reading the access store denies the call rather than falling back to groups. A request with no verified identity is denied restricted tools. A restricted call whose decision cannot be written to the decision log is refused. See restrict tools.
Workflow runs. Scheduled workflows and markdown tasks run as an ordinary account: run_as, else HUBZOID_WORKFLOW_USER, else the owner recorded at setup on a hub managed in the Console. That account's grants decide what the run may do. It is rechecked before every protected call and never swapped for another account. Grants to the older workflow:<name> and workflow:md:<task> service identities are kept, but runs that act as an account do not use them.
Proposed changes. An agent cannot change access. With HUBZOID_MANAGEMENT_TOOLS=true it can propose a change for the signed-in manager, and nothing applies until that manager confirms the exact plan in the Console. A proposal works once and expires.
Account replacement. Hubzoid binds each email to the verified account ID. If a different account later appears with the same email, its direct grants are removed, its agent access is blocked and the event is recorded until an administrator reviews it. The last organization administrator can never be removed, demoted or deleted.
Offboarding. An organization administrator's Delete user removes every grant, then deletes the chat account and its chats. Activity history, usage records and published artifacts are kept, and the email is marked as removed, so a later account with it inherits nothing.
Secrets
| Secret | Where | Protection |
|---|---|---|
| Hub settings and provider keys | <hub>/.env | Listed in the .gitignore that hubzoid init creates. Keep it at mode 600. In a gateway, each hub's .env stays with that hub. Only sign-in and chat app settings missing from the gateway's own environment are taken from it, and the gateway names them at start. |
| Credentials for restricted tools | <hub>/restricted/.env | Loaded into the bridge process at start. The agent's file tools (read_file, list_files, grep_data) refuse every path under restricted/, so the model sees only a tool's result, never the credential. Agent child processes, such as the claude CLI and its MCP servers, do not inherit these values, Hubzoid's service secrets or AWS credentials, unless CLAUDE_CODE_USE_BEDROCK needs them. |
| Optional AWS Secrets Manager secrets | AWS_SECRET_NAME with AWS_REGION (deployment), HUBZOID_HUB_SECRET_NAME in <hub>/.env, HUBZOID_RESTRICTED_SECRET_NAME in restricted/.env | Read at start with boto3's default credential chain. Within a layer the secret wins over the file. A rotation takes effect on restart. hubzoid doctor names each key's layer, never a value. |
| Download-link signing key | <hub>/.hubzoid/artifact_secret | Created with mode 0600. |
| Gateway manifest | <data-dir>/deployment.json | Written with mode 0600, and it can contain database credentials. The pointer file in each hub contains no credentials. |
| Access snapshots | <hub>/.hubzoid/backups/ | Written with mode 0600 by hubzoid access migrate --apply. |
The chat and MCP file tools also refuse dotenv files anywhere in the hub, databases, private runtime folders such as .hubzoid/ and .openwebui-data/, and symlinks into any of them. Open WebUI never receives HUBZOID_* settings.
For an especially sensitive credential, such as SSH access to a production system, run the code that uses it in a separate process under a different operating system user, so the kernel keeps the agent away from it.
Backups leave out .env files, signing keys and database passwords unless you pass --include-secrets. The archive itself is written with owner-only permissions but still contains accounts, password hashes and chats. See backup and restore.
Where data lives
Hubzoid keeps its data on your server, or in PostgreSQL when you configure it. Each model call always leaves the server. Everything else leaves only when you turn it on.
Your server
Hubzoid databases
<hub>/.hubzoid/Grants, identities, the decision log, usage, workflow state, run history and published artifacts.
Chat app data
<hub>/.openwebui-data/Accounts, sessions, chats and uploads.
Inbound channels
<hub>/.inbound/Channel state and stored webhook events.
Logs and files
<hub>/logs/<hub>/output/Runtime logs and files the agent made for people.
Or PostgreSQL for the stores you move there.
Every agent turn
Your model provider
The instructions, context, conversation and tool results for that call.
Only when you configure it
Traces, evals, channels and email
OpenTelemetry, the OpenAI trace dashboard, Langfuse, Slack, WhatsApp, Telegram, SMTP email, Jev, public artifact links and the systems your tools reach.
| Location | Contents |
|---|---|
<hub>/.hubzoid/ | Hubzoid's SQLite databases (grants, identities, decision log, usage, workflow state, WhatsApp and Telegram history), the workflow engine's run history, published artifacts, the signing key |
<hub>/.openwebui-data/ | The chat app's accounts, sessions, chats, uploads and log |
<hub>/.inbound/ | Inbound channel state and stored webhook events |
<hub>/logs/, <hub>/output/ | Runtime logs, and files the agent made for people |
<data-dir>/ (gateway) | The shared chat app database and operational store, and the manifest |
| PostgreSQL, when configured | Whichever stores you moved there. See topologies. |
Usage records hold tokens, cost, model, surface, user and timing, never message content. Chat history lives in the chat app, and the Console never reads it. The chat app is started with administrator access to other people's chats and chat export turned off (ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT). Set either to true in the hub's .env, or the gateway's environment, to allow it.
What leaves the server
| Destination | When | What is sent |
|---|---|---|
| Your model provider | Every agent turn and workflow model call | The instructions, context, conversation and tool results for that call |
| Your OpenTelemetry backend | Only with HUBZOID_OTEL_ENDPOINT set | Traces. See observability. |
| OpenAI's trace dashboard | Only with HUBZOID_OPENAI_TRACING=true | OpenAI Agents SDK traces, including prompts and tool data |
| Langfuse, for eval results | Only with Langfuse keys configured | Eval cases, outputs and scores |
| Slack, WhatsApp, Telegram | Only when those channels are configured | Messages and replies on that platform |
| Your SMTP server | Only when a workflow or markdown task sends email with HUBZOID_SMTP_* set | An email to the run's own account |
| OpenRouter, for Jev | Only when a workflow calls hub.call_jev, or a person granted Call Jev uses it in chat | The state and questions for that decision |
| AWS Secrets Manager | Only with a secret name set | A request for that secret at start |
| Anyone with a public artifact link | Only when a person granted Share artifacts publicly creates a link | That one artifact, until the link expires or is revoked |
| Systems your tools reach | When a tool, connector or MCP server you enabled calls them | Whatever that tool sends |
Hubzoid also starts the chat app with its version update check off, its offline mode on so it does not download models at start, its local embedding model unloaded, and community sharing off.
Your responsibilities
The software enforces the boundaries above. Operating them well is the deployment owner's job:
- Terminate TLS at a reverse proxy and keep the Hubzoid port reachable only through it.
- Set a long random
BRIDGE_API_KEYS, turn sign-in on with a stableWEBUI_SECRET_KEY, and setWEBUI_URL. - If a proxy supplies identity headers, make it strip client-sent copies.
- Protect
.env,restricted/.env, the gateway manifest and backups, and copy backups off the machine. - Never set
HUBZOID_PORTAL_DEVoutside local development. - Grant named people the least access that works, and review the Console's Activity page.
- Offboard with Delete user under People in the Console. The chat app keeps a deleted account's stored connection tokens in its database, so also revoke the person's access in services they connected.
- Choose model providers, telemetry backends and channels that your data governance allows.
- Run
hubzoid doctorafter every change and upgrade to the latest release, which is where fixes go.
Reporting a vulnerability
Report security problems privately through GitHub's private vulnerability reporting on the Hubzoid repository (opens in a new tab): the Security tab, then Report a vulnerability. Please do not open a public issue. Include what you found, how to reproduce it, the output of hubzoid version, and if you can, hubzoid doctor <hub> --json with secrets removed. See SECURITY.md (opens in a new tab).
Next steps
Access administration
Administer accounts and access to each agent, from the first administrator, direct grants and offboarding to workflow identities and moving a hub from chat app groups to managed grants.
Backup and restore
Back up a hub or a whole gateway to one archive while chat keeps working, restore it in place or on a new machine, and include PostgreSQL.
