Authentication Security Audit

Last audit date: 2026-04-23 (phase 6 security hardening refresh). Re-checked against core 2.24.0 on 2026-10-04: the module-boundary, event-name, test-path and replay-evidence sections below were corrected to what that release does.

Scope

This audit covers the authentication, authorization, and request-enforcement paths in MCP Hangar after the phase 6 security hardening work:

  • API key and JWT/OIDC authentication
  • Role-based access control (RBAC) and policy:write authorization
  • Tool access policies (TAP)
  • HTTP and WebSocket auth enforcement
  • Browser-oriented CSRF defense-in-depth on session suspension
  • Core package loading between src/ and src/mcp_hangar/

Current Security Posture

Authentication

  • API requests rely on request.state.auth; WebSocket / generic ASGI paths use scope["auth"].
  • HTTP and WebSocket auth enforcement now share one core implementation in src/mcp_hangar/server/api/middleware.py.
  • WebSocket connections support ?token= bearer mapping for clients that cannot set custom headers.
  • Trusted proxy resolution is centralized through TrustedProxyResolver, preventing spoofed X-Forwarded-For from untrusted peers.

Authorization

  • /api/mcp_servers/{id}/l7_policy (L7 egress policy push) no longer trusts a magic internal header.
  • Policy push now requires an authenticated principal plus policy:write authorization.
  • A refused policy push is recorded as an AuthorizationDenied event (an unauthenticated one is answered 401 before authorization runs). The PolicyPushRejected event type is still defined, but no code path emits it in 2.24.0.
  • The agent role was retired along with the (now-discontinued) cluster agent product; policy:write remains a valid permission and is granted by the built-in admin and provider-admin roles.

Browser CSRF Defense

  • CSRF enforcement is intentionally scoped to browser-style session suspension requests.
  • POST /sessions/{session_id}/suspend requires X-Requested-With only when the request looks browser-originated (Origin, Referer, or Cookie present).
  • API key clients, bearer-token clients, and non-browser API callers bypass the CSRF check.
  • This keeps REST API automation compatible while still defending against browser-triggered session suspension.

WebSocket Security

  • Authentication failures close the socket with code 1008 before websocket.accept(), so the client sees the handshake refused (uvicorn answers it as HTTP 403) and the connection is never used.
  • Origin validation happens before websocket.accept() to mitigate cross-site WebSocket hijacking.
  • The WebSocket endpoints need a WebSocket library in the environment. Since 2.25.0 websockets is a dependency of mcp-hangar. Before that only the container image installed it, and on a pip/uv install of 2.24.0 or earlier the upgrade is never served -- the request is answered as plain HTTP (401 without credentials) (mcp-hangar/mcp-hangar#1676).
  • Per-connection backpressure is enforced with bounded queues.

Module Boundary (optional auth and approvals components)

There is no paid tier or license split: MCP Hangar ships as a single MIT-licensed package. The former enterprise module name was dropped from the codebase; auth and approvals are in-core packages (mcp_hangar.auth, mcp_hangar.approvals).

  • Core bootstrap/router code does not import those packages directly across server modules.
  • They are loaded through one loader, src/mcp_hangar/server/bootstrap/components.py, which bootstraps them when configured and available and substitutes fallback components when they are not.
  • The earlier entry-point discovery and monorepo fallback loader are retired: the separate optional package they discovered no longer exists, so the built-in modules are loaded directly.

Findings Status

FindingStatusNotes
K-1 L7 policy push auth bypassFixed/api/mcp_servers/{id}/l7_policy requires authenticated principal + policy:write; rejection events emitted
K-2 WebSocket auth / CSWSH gapsFixedShared auth enforcement, pre-accept Origin validation, bounded queue backpressure
K-3 Unsafe unauthenticated HTTP exposureFixedNon-loopback HTTP bind blocked without auth unless explicitly overridden
K-4 CORS / host / CSRF hardeningFixedExplicit CORS config, TrustedHostMiddleware, browser-scoped CSRF defense
W-1/W-2 Header identity spoofing via proxiesFixedTrusted proxy resolution centralized and required for forwarded identity trust
W-3 SSRF on remote endpointsFixed (scoped)SSRF validation blocks private/link-local targets for servers registered at runtime (REST API or discovery), and re-checks at connect time. Not covered: a remote server declared in config.yaml, which never reaches the registration handler (before 2.5.1 the connect-time re-check was also lost after a restart and on a follower replica; 2.5.1 carries it on the stored record). See cookbook 23
W-4 Unbounded suspended-session cacheFixedTTL-bounded cache with max size
W-5 JWT algorithm confusionFixedMixed symmetric/asymmetric algorithm families rejected
A-5 Core importing enterprise directlyFixedServer bootstrap/router path loads auth and approvals through one loader (server/bootstrap/components.py)
A-7 Divergent HTTP/WS auth middlewareFixedCore shared auth middleware path now handles both

Recommendations

ItemStatusNotes
API key hash-only storagePassRaw keys are not persisted
JWT algorithm-family validationPassThe OIDC/JWKS validator accepts only RS256 and ES256; HS256 is accepted only by the static-secret validator, so families never mix
Trusted proxy validationPassOnly configured proxies may influence forwarded source identity
WebSocket origin validationPassPerformed before accept
Shared auth logic across protocolsPassOne core implementation reduces drift
Core/optional-component import boundaryPassCentralized in server/bootstrap/components.py
Repo-wide Ruff cleanlinessPassuv run ruff check src/ tests/ reports no findings at 2.24.0
Manual exploit verificationPassReplay on 2.24.0 confirmed K-1 returns 401, K-2 refuses the unauthenticated or wrong-Origin handshake (403), K-3 exits 1 on a non-loopback no-auth start, and K-4 rejects a hostile Host (400 on /api, 421 on /mcp)
TLS / mTLS at deployment edgeManualMust be enforced by deployment topology / reverse proxy

Verification Evidence

Re-run against core 2.24.0 on 2026-10-04:

  • uv run ruff check src/ tests/ and uv run mypy src/ -- pass
  • Manual exploit replay of K-1..K-4 on a live gateway -- pass: /api/mcp_servers/{id}/l7_policy without credentials returns 401 and with a developer key 403; an unauthenticated WebSocket, or one from an unlisted Origin, is refused at the handshake (403); a no-auth start on the default 0.0.0.0 exits 1 with http_auth_required_for_non_loopback; a hostile Host is rejected with 400 on /api and 421 on /mcp
  • Focused security and boundary suites (52 tests, pass):
    • tests/unit/test_security_critical_paths.py
    • tests/unit/test_security_identity_and_network.py
    • tests/unit/test_bootstrap_components_boundary.py
    • tests/unit/test_bootstrap_components_loading.py
    • tests/unit/test_api_auth_enforcement.py
    • tests/unit/test_ws_auth.py

Open Items

Open defects in this audit's scope, filed publicly:

Fixed in 2.25.0. A gateway older than that still has them:

  • A tools: access policy with one invalid field was dropped whole with only a warning, so the gateway enforced no policy for that server -- denied and approval-listed tools ran (mcp-hangar/mcp-hangar#1648); and a policy list written as a string was split into its characters, so deny_list: add allowed add (mcp-hangar/mcp-hangar#1718). Since 2.25.0 either refuses the boot, and a reload, naming the scope and the field.
  • The auth routes took assigned_by / created_by / revoked_by / updated_by from the request body, so the actor recorded for a key or role change was caller-supplied (mcp-hangar/mcp-hangar#1649). Since 2.25.0 the actor is the authenticated principal (anonymous with auth off), and a body carrying one of those fields is refused with 422. An auth event written by an older gateway names whoever its request body said.