12 -- Auth & RBAC
Prerequisite: 01 -- HTTP Gateway You will need: Running Hangar in HTTP mode Time: 10 minutes Adds: API key authentication and role-based access control
The Problem
Your Hangar instance is accessible to everyone on the network. You need to control who can invoke tools, start MCP servers, or manage configuration. Different teams need different access levels.
The Config
# config.yaml -- Recipe 12: Auth & RBAC
mcp_servers:
my-mcp:
mode: remote
endpoint: "http://localhost:8080/mcp"
health_check_interval_s: 10
auth: # NEW: authentication config
enabled: true # NEW: enable auth
allow_anonymous: false # NEW: require auth for all requests
api_key: # NEW: API key config
enabled: true # NEW: enable API key auth
header_name: X-API-Key # NEW: header to read key from
storage: # NEW: durable, and required below
driver: sqlite # the default is `memory`
path: data/auth.db
The storage block is not optional here. Every /api/auth/** route requires an
admin principal, so there is no unauthenticated call that mints the first key --
POST /api/auth/keys on a fresh gateway answers 401. The way out is the CLI,
and it refuses the default memory driver, because a key minted into memory is
gone the moment the process it was minted in exits.
Try It
-
Start Hangar:
mcp-hangar serve --http --host 127.0.0.1 --port 8000 -
Try an unauthenticated request -- it fails:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/mcp_servers401 -
Mint the first key. Not over HTTP:
/api/auth/**requires an admin principal with no carve-out for the first call, so an unauthenticatedPOST /api/auth/keysanswers401. Stop the gateway and run:mcp-hangar auth bootstrap-admin --config config.yaml \ --principal service:my-app --show-keyInitial global admin bootstrapped. principal : service:my-app key id : N3xQ... actor : local-cli-bootstrap api key : mcp_QWBXSRQ4OW... This secret is shown once and is not recoverable. It is stored hashed. Anyone holding it is a global administrator of this deployment.--show-keymatters and the claim is one-shot. Without the flag the command prints no secret -- right when the principal is an OIDC subject that authenticates on its own identity, and useless when API keys are all you have. On this recipe's configuration, which has noauth.oidcblock, omitting it is therefore refused before the claim is spent:Error: Nothing could use this administrator: API keys are the only authenticator, and the key's secret would not be printed.So a forgotten flag costs you one command. What it used to cost is the deployment: the claim was spent, and a second run exits 1 with "the initial administrator has already been bootstrapped" while the secret sits in the database as a hash.
Before 2.5.0 the secret was discarded unconditionally and this recipe's configuration had no way to reach its own gateway.
Start the gateway again. With that key in hand, later keys are ordinary API calls:
curl -X POST http://localhost:8000/api/auth/keys \ -H "X-API-Key: <the key from bootstrap>" \ -H "Content-Type: application/json" \ -d '{"principal_id": "service:my-app", "name": "My App Key"}'{"key_id": "...", "raw_key": "mcp_aBcDeFg...", "principal_id": "service:my-app", "name": "My App Key", "expires_at": null, "warning": "Save this key now - it cannot be retrieved later!"}Save the
raw_key-- it is shown only once. -
Use the key:
curl -H "X-API-Key: mcp_aBcDeFg..." http://localhost:8000/api/mcp_servers/Keep the trailing slash: without it the gateway answers an authenticated request with a
307redirect and an empty body.{"mcp_servers": [...]} -
Assign a role:
curl -X POST http://localhost:8000/api/auth/roles/assign \ -H "X-API-Key: mcp_admin_key..." \ -H "Content-Type: application/json" \ -d '{"principal_id": "service:my-app", "role_name": "developer"}' -
Set a tool access policy. The lists hold tool name patterns, not principals: this one leaves only
addandsubtractcallable onmy-mcp, for every caller.curl -X POST http://localhost:8000/api/auth/policies/provider/my-mcp \ -H "X-API-Key: mcp_admin_key..." \ -H "Content-Type: application/json" \ -d '{"allow_list": ["add", "subtract"], "deny_list": []}'{"scope":"provider","target_id":"my-mcp","allow_list":["add","subtract"],"deny_list":[],"set":true}A call to any other tool now fails with
Tool not available for this mcp_server. A principal id inallow_listmatches no tool, so it would block every tool on the server.
What Just Happened
Enabling auth adds the AuthMiddleware to the HTTP stack. Every request must include a valid API key in the X-API-Key header. The key is hashed and looked up in the auth store. The principal's roles determine what operations are allowed.
Built-in roles:
| Role | Can do |
|---|---|
admin | Everything |
provider-admin | Read servers and deliver compiled egress policy (policy:write); cannot create, delete, start or stop a server through the API. Enough for an operator that only delivers policy; one that registers remote servers needs admin today (see the authentication guide). |
developer | Invoke tools, read and manage server state. Cannot touch egress policy. |
viewer | Read-only access |
auditor | Read-only access to audit logs and metrics |
service-account | Tool invocation only |
See Authentication for each role's permissions.
Tool access policies add fine-grained control per MCP server, group, or group
member (the provider, group and member scopes): which tool names may be
called there. They name tools, not callers; who may call at all is the role's
job.
Key Config Reference
| Key | Type | Default | Description |
|---|---|---|---|
auth.enabled | bool | false | Enable authentication |
auth.allow_anonymous | bool | false | Allow unauthenticated requests |
auth.api_key.enabled | bool | true | Enable API key authentication |
auth.api_key.header_name | string | X-API-Key | HTTP header for API key |
What's Next
You've secured access. Before going to production, run through the full checklist.