| Version | Supported |
|---|---|
| latest | ✅ |
Understanding what ha-mcp does and doesn't defend against helps you write accurate reports and helps us triage quickly.
An authenticated MCP client (one that has the secret URL or completed the OAuth flow) is a trusted principal. ha-mcp does not attempt to defend against a malicious or compromised MCP client — that is equivalent to defending against a malicious user who has your Home Assistant long-lived access token.
Consequences:
- Prompt injection that reaches an MCP client is the client's responsibility to defend against. If the LLM embedded in a client is tricked into calling a tool it shouldn't, that is a client-side control issue, not a server-side vulnerability.
python_transformexpressions are treated as trusted code from a trusted client. The sandbox exists to prevent accidental mistakes (e.g. a runaway loop), not to sandbox an adversarial party. Additionally,python_transformonly ever receives data that ha-mcp fetched from the HA API and JSON-deserialized — no Python callables can reach the expression'sconfigvariable through any normal MCP or HA API call. See python_sandbox.py for the explicit "not a security boundary" note.
The HTTP entrypoint (ha-mcp-web) authenticates by URL-path
secrecy and is designed for loopback HTTP or LAN HTTP with a high-entropy
MCP_SECRET_PATH. Any peer that can reach the configured path is treated as
trusted — securing the local network is outside ha-mcp's scope.
For internet-facing deployments use the OAuth entrypoint (ha-mcp-oauth) or,
for gating access behind an external identity provider instead of per-user HA
tokens, the OIDC entrypoint (ha-mcp-oidc; see docs/oidc.md),
behind a TLS-terminating reverse proxy (see
OAuth Mode below). Deployment guidance:
AGENTS.md → Docker.
By default the HTTP entrypoints bind to 0.0.0.0 so they are reachable from
other machines on the LAN. To restrict to the local machine, set
MCP_HOST=127.0.0.1 (or use -p 127.0.0.1:8086:8086 at the Docker layer).
fastmcp ships a Host/Origin guard — a DNS-rebinding defense that only accepts
loopback Host headers and same-origin/loopback Origins. ha-mcp defaults it
off (FASTMCP_HTTP_HOST_ORIGIN_PROTECTION=false) across its Streamable-HTTP
entry points (ha-mcp-web, ha-mcp-oauth, ha-mcp-oidc, the app (add-on), and the
in-process component server). The supported
deployment model — reverse proxies, tunnels (Cloudflare, Nabu Casa), and direct
LAN access — presents Host headers ha-mcp cannot enumerate, and the guard
would otherwise reject them with 421/403 (including the plain browser landing
page, a no-Origin navigation that still trips the Host check).
This does not change the boundary defined above. URL-path secrecy (standard mode), the OAuth / Home Assistant session gates (OAuth and in-process modes), and the external identity provider gate (OIDC mode) remain the authentication boundary, and the local network is already the trusted zone — so the DNS-rebinding class this guard addresses is out of scope regardless. A DNS-rebinding attacker's browser still cannot reach the secret MCP path — only the public OAuth/OIDC discovery documents at fixed well-known paths (in standard/OAuth HTTP modes, the landing page shares the secret path) — and the loopback settings sidecar enforces its own Host/Origin allow-list independent of this setting.
Operators who front ha-mcp differently can re-enable the guard by setting
FASTMCP_HTTP_HOST_ORIGIN_PROTECTION=true and pinning
FASTMCP_HTTP_ALLOWED_HOSTS / FASTMCP_HTTP_ALLOWED_ORIGINS.
The secret-URL model (ha-mcp-web) assumes a single operator.
All MCP clients that share the same MCP_SECRET_PATH get identical access —
there is no per-client authorization or isolation. Reports that assume "client A
shouldn't be able to see client B's data" don't apply to standard mode; that
isolation model only exists in OAuth mode (scoped per HA token).
ha-mcp uses the long-lived access token the operator provides. That token's permissions in Home Assistant are what they are. If the configured token is an admin token, ha-mcp can perform admin-level operations. Reports stating "ha-mcp can do X" where X is permitted by the configured token are not vulnerabilities — they are the intended behavior. Restricting HA permissions is done in Home Assistant (e.g. by creating a non-admin user and generating a token for that user).
The opt-in entity visibility filter's enforce mode ("enforce": true, see
the Entity visibility filter FAQ) makes a hidden set
unreadable across ordinary tool reads: direct reads of a hidden entity are
refused with a generic not-found before the tool runs, and scannable content
reads (dashboards, templates, automations, traces, logs, files) that would
surface a hidden entity_id are refused on contact. Those enforced paths fail
closed when registry data cannot be loaded.
One diagnostic path is deliberately outside that guarantee by default:
ha_report_issue bypasses both scans while restrict_report_issue is false,
even when visibility data is healthy, and its report may contain logs naming a
hidden entity. This is the troubleshooting escape hatch when the filter itself
fails; operators can opt it into enforcement. A config that cannot be loaded
with no last-good copy follows that unrestricted default for this one tool while
all other calls fail closed.
This is a strong barrier against an agent incidentally surfacing a hidden
entity, not a cryptographic guarantee: enforcement scans for the hidden
entity_id, so a Jinja template or sandbox-adjacent computation that derives a
hidden entity's state without naming its entity_id cannot be caught. On enforced
scannable paths, a matching hidden entity's data is not returned. Existence
concealment is best-effort: per-tool not-found shapes vary, so a prober comparing
errors may infer that an id is hidden rather than absent. The boundary covers
Home Assistant entity data, not physical-world imagery: a visible camera stays
readable (ha_get_camera_image) and could incidentally show a display that
renders a hidden entity's state — hide that camera when its view is sensitive.
Enforce mode is not a defense against an adversarial prompt author deliberately
trying to exfiltrate a hidden entity's state, nor a substitute for Home
Assistant's permission model; restrict what the configured token can reach in
HA for a hard boundary.
In OAuth mode, access and refresh tokens are HMAC-signed, stateless Bearer tokens. The token payload contains the user's Home Assistant long-lived access token (LLAT). This is by design:
- The LLAT is the authorization boundary. Revoking it in Home Assistant immediately invalidates all derived tokens — that is the intended revocation path.
- Tokens are HMAC-signed (preventing forgery and tampering) but not encrypted. Encrypting the payload would not improve security: the LLAT must ultimately be sent to Home Assistant in cleartext over HTTPS to authenticate API calls. Anyone with access to the MCP server process can observe the LLAT regardless of token format.
- A party that captures a token can decode it to recover the LLAT. This is
equivalent to capturing any other Bearer token that grants the same access —
including the standard OAuth
client_credentialsmodel used by many MCP clients, where a staticclient_secretstored at the AI provider grants full service access. The trust boundary is identical; the only difference is packaging. - Token revocation at the ha-mcp level is a no-op: there is no server-side token store. Revoke the LLAT in Home Assistant instead.
The consent form explains this revocation path. Reports about token opacity (the LLAT being visible inside the token) will be closed as by-design.
The ha_mcp_tools component's in-process MCP server config entry can run the
ha-mcp server in-process inside Home Assistant and expose it through a Home
Assistant webhook (see docs/in-process-server.md).
It offers three authentication postures, selected by the Authentication
mode option in the entry options:
- Secret webhook URL (default,
none). The webhook id is a high-entropy random string and is the credential — the same secret-URL trust model as standard mode above, except the URL is designed to be reached remotely through Home Assistant's own remote access (Nabu Casa or a TLS-terminating reverse proxy). Any party that has the full webhook URL is a trusted principal; keep the URL secret. The secret URL is the main and only form of security in this mode. The mode also serves an OAuth compatibility surface (discovery documents, an anonymous RFC 7591 registration endpoint, and an auto-approve authorization server) purely so OAuth-insisting connector brokers can complete a flow: the tokens it issues are cosmetic — bearers are ignored, the webhook URL remains the only credential. The auto-approve endpoint 302-redirects to any spec-validredirect_uri(https, or http loopback per RFC 8252; no fragment), which makes the Home Assistant origin usable as a crafted-link redirector — an accepted trade within this trust model (maintainer decision 2026-08-14, superseding the exact-match callback allowlist that shipped in #1976; the webhook-id protections from that PR are unchanged). - Home Assistant account (
ha_auth). Home Assistant Core is the OAuth authorization server: the entry serves the discovery documents and validates inbound Bearer tokens against Home Assistant's own auth, so access is gated by a Home Assistant login — and restricted to administrator users. The server acts with its own provisioned admin token (the caller's bearer is never forwarded), so accepting any valid login would grant every household member admin-equivalent control; non-admin, inactive, and system-generated users are rejected. This is distinct from the beta OAuth mode below — no bespoke authorization server or self-issued token is involved, and revoking the user's Home Assistant token/session revokes access. The component-scoped authorize/token endpoints front Core's own/auth/*(a browser redirect and a server-side token forward) so the URLs clients cache are the component's — Core remains the authorization authority and performs its own validation on every request. For URL-shaped client identities Core would reject (cross-origin Client ID Metadata Document clients), the component validates the CIMD document itself per the MCP 2026-07-28 requirements (https-only fetch with no redirects, 10 KiB cap, exactclient_idround-trip,redirect_urismatch, loopback/IP-literal hosts refused) and forwards a same-origin-shaped client_id. This grants nothing new: Core already accepts any self-asserted redirect-origin client_id, so a validated translation authorizes only what a client could claim directly; anything failing validation is forwarded unchanged. The anonymous registration endpoint mints stateless public-client ids (HMAC-signed, embedding the registered redirect URIs — no server-side registration store); access still requires completing Core's admin login. - Legacy OAuth (
legacy). A self-hosted OAuth 2.1 authorization server the component serves on its scoped endpoints (/api/ha_mcp_tools/oauth/authorize/token, which discovery advertises) and additionally at the Home Assistant root (/authorize+/token) as an alias for metadata-ignoring clients, for OAuth-only MCP clients that HA Core's native OAuth cannot serve (Google Gemini Spark's cross-origin Client-ID-Metadata-Document redirect, GitHub Copilot CLI's dynamic registration). The credential is a staticclient_id+client_secretthe component generates (or the admin overrides), plus a signing key — all persisted in the config entry. Its security properties:
- The client secret is the boundary. Anyone holding the
client_id+client_secretcan complete the flow and mint tokens; there is no per-user identity. Access is admin-equivalent — the same provisioned admin token backs it as the other modes. Keep the secret secret. - Self-issued Bearer tokens, HMAC-signed and stateless, carrying
{kind, iat, exp, jti, cid}— no Home Assistant LLAT (unlike the beta OAuth mode in "OAuth Bearer token design" above; that section's LLAT-revocation model does not apply here). Access tokens live 1 hour, refresh tokens 30 days. - Revocation is rotation + restart. Regenerating the credential or
changing the
client_id/client_secretoverride rotates the signing key, which invalidates every outstanding token — but only once Home Assistant restarts, because the root/authorize//tokenviews cannot be rebound without a restart (a repair issue prompts for it). Until that restart the previous credential keeps working; the startup log withholds the rotated credential during that window so a still-valid old token cannot read it. - The consent endpoint is unauthenticated (no HA session) — it is a
plain human-approval page. This is safe because the authorization code is
inert without the
client_secretat the token endpoint (the client is authenticated before any code is redeemed) and PKCE S256 binds the code to the caller. - Redirect URIs are validated to a spec floor, not exact-matched: any
https://URL (orhttp://loopback per RFC 8252, for CLI clients on variable ports) with a valid host/port and no fragment is accepted. There is deliberately no per-client redirect allowlist — the mode exists precisely for clients whose redirect URIs cannot be pre-registered (Spark's is cross-origin; Copilot CLI's loopback port varies). A permissive redirect is not exploitable for token theft here (the code is inert without the secret), so this is an accepted deviation from RFC 9700's exact-match guidance, scoped to this single-tenant self-hosted AS. - TLS is required in practice — the endpoints ride Home Assistant's own HTTP, so expose them only over HA's HTTPS remote access (Nabu Casa or a TLS-terminating reverse proxy), never plaintext over the internet.
- Route ownership: the component and the Webhook Proxy app both bind
the root
/authorize//token; only one may own them per Home Assistant instance. When the app already owns them, the component's legacy mode still enables and serves on its scoped endpoints only, logging a warning — metadata-honoring clients are unaffected; metadata-ignoring clients that guess root paths reach the app instead.
The connect notification deliberately carries no secrets: Home Assistant shows persistent notifications to every authenticated user, so the webhook URL (the credential in the default posture) is surfaced only on administrator-only surfaces - the entry's Configure screen, the sidebar panel, and the log. A local-only option removes the webhook entirely.
The server reaches Home Assistant with a dedicated admin token the component provisions and stores in the config entry. The token is handed to the server in-memory (never through the Home Assistant process environment); removing the entry revokes it, and disabling the config entry stops the server. As with standard mode, that token's Home Assistant permissions define what the server can do.
The component also adds an admin-only settings panel to the Home Assistant sidebar that reverse-proxies the server's web settings UI over Home Assistant's own HTTP. Because a browser cannot attach a Bearer token to a panel view, access is gated by a short-lived, HttpOnly session cookie that an authenticated administrator obtains through Home Assistant, and every proxied request re-validates that the session still maps to an active admin. The loopback secret path is never exposed to the browser and no token or secret is placed in a URL; the proxy returns 503 whenever the server is not running.
In the app's ha_auth mode the Home Assistant login is the credential, not
the webhook URL. The URL without a Bearer gets a 401, and the URL is not
withheld: as in any OAuth mode, the RFC 9728 protected-resource document
advertises it at a fixed unauthenticated path so clients can discover the
endpoint. (This is the difference from the app's default posture with OAuth
disabled, where the URL is withheld because it alone is the credential.)
ha_auth is an access gate, not per-user authorization. It validates the
inbound Bearer against Home Assistant core and accepts any token core still
honors, without inspecting the backing account: administrators,
non-administrators, and system-generated accounts are all accepted alike. (Home
Assistant core itself rejects a deactivated user's tokens, so deactivation does
revoke proxy access.) The in-process entry above, by contrast, accepts only
active human administrators. The caller's Bearer is never forwarded upstream:
the ha-mcp app services every request with its own
Supervisor/homeassistant_api privileges regardless of which account
authenticated. A caller holding a credential Home Assistant core honors is a
trusted principal per "MCP clients are trusted principals" above, which draws
the line at possession of a working credential — not at holding any particular
Home Assistant role.
Treat a Home Assistant account on this instance as admin-equivalent wherever the
app runs ha_auth: every account that can sign in reaches the same tool
surface. Because the gate never inspects the account's role, demoting a user out
of the administrator group does not revoke their access through the proxy —
deactivate the account, which Home Assistant honors immediately by dropping that
user's refresh tokens. Turning the app's OAuth mode off revokes nobody, and
loosens the boundary: the webhook id persists across the switch, so that posture
falls back to treating as the sole credential a URL every ha_auth client
already holds — and one that was advertised anonymously the whole time ha_auth
was on. Treat any webhook id that has served ha_auth as public, and rotate it
(the app's DOCS.md, "Rotating the webhook URL") when moving to the URL-secret
posture. The in-process entry's admin restriction is a property of that entry,
not a guarantee of the app.
The app's legacy mode carries the same properties as the in-process
legacy mode described above — the component's implementation was ported from
this app's — including the static client_id/client_secret as the sole
boundary, admin-equivalent access, self-issued HMAC bearers, and revocation by
credential rotation plus a restart. Only one of the two may own the root
/authorize and /token routes per Home Assistant instance.
In scope — please report these:
- Authentication bypass in standard (LLAT), OAuth, or OIDC mode
- OAuth mode: XSS, SSRF, open redirect, or credential exfiltration via the consent form or token endpoint (i.e. an unauthenticated party obtaining a token or extracting credentials without completing the consent flow)
- Unintended information disclosure via API responses (e.g. secrets returned to a client that shouldn't have them)
- Privilege escalation within the MCP tool surface
- Dependency vulnerabilities with a credible exploit path
Out of scope — these will not be actioned:
- Vulnerabilities in Home Assistant itself → report to home-assistant/core
- Vulnerabilities in Nabu Casa or other remote access infrastructure
- Attacks requiring physical access to the HA host
- "The LLM performed a destructive action using valid, authorized tools" —
this is a configuration or usage issue, not a security vulnerability.
Tool visibility controls (
ENABLED_TOOL_MODULES, group toggles) exist for this purpose. - Prompt injection that only travels through read-only tool return values — the MCP client controls what the LLM sees and acts on; hardening that path is the client's responsibility (see Threat Model above).
python_transformissues: the sandbox is not a security boundary between trusted and untrusted code. Problems withpython_transformbehavior are bugs, not security vulnerabilities.- LAN-peer access to standard-mode HTTP endpoints: the local network is the trusted zone (see Threat Model above).
- DNS rebinding against the HTTP entrypoints: fastmcp's Host/Origin guard is off by default; URL-path secrecy is the boundary and the local network is the trusted zone (see Threat Model above).
- The web settings UI not being gated by the OAuth/OIDC token: fastmcp custom
routes bypass the auth middleware by design, so a dedicated secret path gates
it instead (see OAuth Mode below). Reports
placing the settings routes at
<MCP_SECRET_PATH>/settingsin OAuth or OIDC mode describe pre-7.14.0 behavior. - OAuth token containing an encoded LLAT: this is the Bearer token design (see Threat Model above).
- OAuth token revocation not preventing further HA API access: revoke the LLAT in Home Assistant instead.
- Saved custom tools (code mode, off by default) visible to other clients of the
same server process: they are shared, persistent scaffolding — not per-user
data — and
run_savedexecutes with the caller's own HA token, granting no access the caller lacks. Any client that can reach ha-mcp is a trusted principal (see Threat Model above). - Any Home Assistant account being accepted by the Webhook Proxy app's
ha_authmode — non-administrators and system-generated accounts alike: holding a credential Home Assistant core honors makes a caller a trusted principal (see Threat Model above), which is the bar that mode sets.ha_authis an access gate, not per-user authorization; the in-process entry's admin restriction is a property of that entry, not a guarantee of the app. - Vulnerabilities that are only exploitable due to a misconfigured deployment
(e.g., standard-mode instance exposed to the internet without TLS, or a
network-reachable HTTP entrypoint using the default
MCP_SECRET_PATH).
The OAuth consent-flow mode (ha-mcp-oauth entrypoint) is experimental
and carries a larger attack surface than the standard LLAT setup.
- Not recommended for production without TLS and network access restrictions
- Requires explicit opt-in (
ha-mcp-oauth); the default entrypoint is unaffected - CVEs were published and fixed in v7.x (XSS: GHSA-pf93-j98v-25pv; SSRF: GHSA-fmfg-9g7c-3vq7). Upgrade to the latest release before deploying.
- The web settings UI is not gated by the OAuth token. It is served as
fastmcp custom routes, which bypass
RequireAuthMiddleware, somcp.authcovers the MCP protocol endpoints only. Since 7.14.0 the UI is mounted under its own dedicated secret path — auto-generated as/private_<token>unlessMCP_SETTINGS_SECRET_PATHis set, never advertised to MCP clients, and printed only in the startup log (GHSA-mx64-982r-65vg). That path is a credential: the surface behind it edits feature flags (includingread_only_mode,redact_secrets, and the filesystem tools), tool configuration, the tool-security policy and entity visibility, and can restore or delete backups. SetHA_MCP_DISABLE_SETTINGS_UIto not serve the UI at all. Standard mode instead mounts the UI under the MCP secret path, which already gates the tool surface. The app mounts it twice: under that secret path for direct access, and at the bare root for Home Assistant ingress, where the routes admit only the Supervisor peer (172.30.32.2) and 403 every other caller.
If you choose to run OAuth mode, restrict the consent endpoint to trusted networks and place it behind a TLS-terminating reverse proxy.
The OIDC entrypoint (ha-mcp-oidc) is also new. It proxies
authentication to your external identity provider via FastMCP's
OAuthProxy — unlike OAuth mode's self-contained authorization server —
and, like OAuth mode, exposes dynamic client registration (DCR) to any
client that can reach the discovery endpoints. The same TLS/reverse-proxy
recommendations apply, and
OIDC_ALLOWED_CLIENT_REDIRECT_URIS should be set for internet-facing
deployments (see docs/oidc.md). The settings-UI caveat above
applies identically: MCP_SETTINGS_SECRET_PATH, not the OIDC token, is what
gates it.
Three further properties of the mode:
- OIDC is an access gate, not per-user authorization. Every authenticated
user shares the one server-side
HOMEASSISTANT_TOKEN, so all requests act as the same Home Assistant identity. As in standard mode, there is no per-user isolation — reports assuming user A cannot reach user B's data do not apply. Per-user Home Assistant credentials exist only in OAuth mode. - The token audience is not checked by default. With
OIDC_AUDIENCEunset andOIDC_VERIFY_ID_TOKENoff, FastMCP's JWT verifier checks issuer, signature, and expiry but notaud. That is fine on an IdP dedicated to ha-mcp, and weaker on a shared one, where a token another client obtained from the same issuer would also pass. SetOIDC_AUDIENCEon a shared IdP. - Sessions persist across restarts, and revocation is rotation. When
OIDC_JWT_SIGNING_KEYis unset, the session signing key is derived deterministically fromOIDC_CLIENT_SECRET, so a restart does not log users out. To invalidate every outstanding session, rotateOIDC_JWT_SIGNING_KEYif it is set, andOIDC_CLIENT_SECRETotherwise.
Use the private reporting page at: https://github.com/homeassistant-ai/ha-mcp/security/advisories/new
We aim to acknowledge reports within 7 days and to provide an initial assessment shortly after. Remediation time depends on severity and complexity. We practice coordinated disclosure and will agree a timeline with you, typically within 90 days of the initial report. Severity is assessed using CVSS base scores where applicable.
Requirements for a valid report:
- Reports must be made in good faith
- Demonstrate a real, reproducible issue with steps to reproduce
- Accurately reflect severity and impact — overstated reports are deprioritized
- Low-quality or AI-generated submissions without a working proof of concept will be closed without action