---
title: Security model
description: How a Hubzoid deployment is protected, covering network exposure, sign-in, fail-closed authorization, secrets, MCP keys, data locations and egress.
canonical_url: https://hubzoid.com/docs/deploy/security-model
last_updated: 2026-09-27
---

# 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](https://hubzoid.com/docs/deploy/security-model#reporting-a-vulnerability).

TLS proxy → Edge → Chat app sign-in → Bridge key → Agent entry → Tool permission → Decision log

The checks a chat request passes before a restricted tool runs.

## 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-*` and `X-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_KEY` after 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.

**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](https://hubzoid.com/docs/deploy/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](https://hubzoid.com/docs/guides/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](https://hubzoid.com/docs/guides/tools-and-connectors#per-user-mcp-servers).

**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](https://hubzoid.com/docs/guides/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:

1. **The surface gate.** The request's surface must be allowed to reach restricted tools at all. The default list is `owui`, `web`, `api`, `mcp` and `workflow`, 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_SURFACES` replaces the list. `slack-dm` is safe to add, and `slack-channel` must never be added, because a shared channel thread is answered under one person's identity.
2. **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](https://hubzoid.com/docs/guides/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](https://hubzoid.com/docs/deploy/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.

| 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](https://hubzoid.com/docs/deploy/topologies#where-data-lives). |

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](https://hubzoid.com/docs/deploy/observability#opentelemetry-traces). |
| 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 stable `WEBUI_SECRET_KEY`, and set `WEBUI_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_DEV` outside 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 doctor` after 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](https://github.com/hubzoid/hubzoid): 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`](https://github.com/hubzoid/hubzoid/blob/main/SECURITY.md).

## Next steps

- [Identity and access](https://hubzoid.com/docs/concepts/identity-and-access): How identity reaches every tool call.
- [Access administration](https://hubzoid.com/docs/deploy/access-administration): Grants, administrators and migration from groups.
- [Authentication](https://hubzoid.com/docs/deploy/authentication): Sign-in methods and their settings.
- [Security at Hubzoid](https://hubzoid.com/security): The overview for security reviewers.
