17 -- Multi-Issuer Front Door
Prerequisite: 16 -- Front-Door Multi-Tenant You will need: MCP Hangar 1.6.0 or later, two OIDC issuers that mint JWTs with a
tenant_idclaim andaudset to your resource URI Time: ~15 minutes Adds: Multi-issuer OAuth trust, RFC 8707 resource-bound audience, RFC 9728 multi-issuer discovery
The Problem
Recipe 16 fronted untrusted agents that all came from a single IdP. Real SaaS is messier: each customer brings their own identity provider. Customer A authenticates agents through Keycloak; Customer B uses Auth0. You still want one Hangar front door, but it now has to trust two authorization servers at once -- and it must not silently accept a token from some third issuer that nobody onboarded.
A single auth.oidc.issuer cannot express that. You need a list of trusted
issuers, each with its own JWKS, and a way to prove -- auditably -- that every
accepted token was minted for this Hangar and not replayed from another
resource. Hangar 1.4 adds a multi-issuer trust list plus RFC 8707 audience
binding: when you set auth.oidc.resource_uri, the value you advertise as the
RFC 9728 resource is the exact value enforced as each token's aud.
The Config
# config.yaml -- Recipe 17: Multi-Issuer Front Door
tool_access:
mode: front_door # face untrusted callers, fail-closed
auth: # validate JWTs; Hangar does not issue them
enabled: true
allow_anonymous: false
oidc:
enabled: true
resource_uri: https://hangar.example.com # NEW: advertised AND enforced as aud (RFC 8707)
tenant_claim: tenant_id # default claim -> CallerIdentity.tenant_id
issuers: # NEW: trust list; overrides legacy single `issuer`
- issuer: https://issuer-a.example.com # Customer A (e.g. Keycloak)
audience: https://hangar.example.com # ignored while resource_uri is set
jwks_uri: https://issuer-a.example.com/jwks
allowed_tenants: ["tenant:a"] # NEW: the only tenant this issuer may name
- issuer: https://issuer-b.example.com # Customer B (e.g. Auth0)
audience: https://hangar.example.com # ignored while resource_uri is set
jwks_uri: https://issuer-b.example.com/jwks
groups_claim: roles # per-issuer override; tenant_claim inherits top-level
allowed_tenants: ["tenant:b"]
role_assignments: # NEW: one tenant-scoped grant per tenant
- principal: "group:agents"
role: service-account # invoke tools; no hangar_* management tools
scope: "tenant:tenant:a" # `tenant:` + the tenant id, here `tenant:a`
- principal: "group:agents"
role: service-account
scope: "tenant:tenant:b"
mcp_servers:
payments:
mode: remote
endpoint: http://localhost:8080/mcp
description: "Payments backend"
tool_access: # per-tenant (member-scope) policy
member:
"tenant:a":
allow_list: [charge]
"tenant:b":
allow_list: [charge, refund]
Save this as ~/.config/mcp-hangar/config.yaml or pass it with --config.
Bind each issuer to its tenants. Since 2.34.2 each issuer entry takes
allowed_tenants. A bound issuer's token is refused with 401 unless its tenant
claim is a string in the list, and a token naming no tenant is refused too.
Without it, an issuer could sign a token naming any tenant -- issuer-b could mint
tenant_id: tenant:a -- and its sub and groups would become the same
principal and groups as issuer-a's. With two or more issuers and any of them
unbound, boot logs one oidc_issuers_not_bound_to_tenants warning naming them.
The list must name at least one tenant; an empty list, a bare string or the key
with no value refuses the configuration, and so does the key set at the top
level of auth.oidc.
Grant roles at tenant scope. A caller with no role sees its tenant's tool
names and is refused every call (tool:invoke permission required). The
binding above confines tenant-scoped roles only: a role assigned at scope
global to a subject or to group:<name> still reaches a token with that
subject or group from any trusted issuer. So with more than one issuer, grant
OIDC users and groups only tenant:<id> roles, as above, and do not list one
tenant under two issuers. A role's scope is tenant: followed by the tenant id
the token carries; this recipe's tenant ids are tenant:a and tenant:b, which
makes the scopes tenant:tenant:a and tenant:tenant:b. Each IdP is expected
to put its agents in an agents group (issuer-b reads groups from roles).
Because resource_uri is set, each issuer's audience is overridden at
validation time: every token -- from issuer-a or issuer-b -- must carry
aud: https://hangar.example.com. The per-issuer audience lines are kept
documented above but are inert until you remove resource_uri.
Try It
-
Start Hangar in front-door mode
mcp-hangar --config ~/.config/mcp-hangar/config.yaml serve \ --http --host 0.0.0.0 --port 8000With
tool_access.mode: front_doorandauth.allow_anonymous: false, every tool call requires an authenticated tenant from one of the trusted issuers. -
Discover the OAuth resource metadata (no token needed)
curl -s http://localhost:8000/.well-known/oauth-protected-resource | jqExpected output:
{ "resource": "https://hangar.example.com", "authorization_servers": [ "https://issuer-a.example.com", "https://issuer-b.example.com" ] }This is the RFC 9728 document.
resourcecomes fromauth.oidc.resource_uri;authorization_serverslists every entry inauth.oidc.issuers, not a single one. A client discovers both authorization servers it may legitimately get a token from. -
Confirm unauthenticated calls are denied (fail-closed)
curl -s -i http://localhost:8000/mcp \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' \ | grep -i "^HTTP\|^WWW-Authenticate"Expected output:
HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://hangar.example.com/.well-known/oauth-protected-resource", ApiKeyThe
WWW-Authenticateheader points the agent back at the discovery endpoint. (Without OIDC configured the challenge would be justBearer, ApiKey.) -
Call a tool as a tenant from issuer-a
Obtain a JWT from Customer A's IdP whose
issishttps://issuer-a.example.com,audishttps://hangar.example.com,tenant_idistenant:aandgroupsincludesagents, then list tools.curl -s http://localhost:8000/mcp \ -H "Authorization: Bearer $ISSUER_A_JWT" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' \ | sed -n 's/^data: //p' | jq '.result.tools[].name'Expected output for
tenant:a(allow_list ofcharge):"charge" "hangar_whoami"hangar_whoamiis listed to every authenticated caller; calling it shows the principal, tenant and theservice-accountbinding attenant:tenant:athe call was judged by. -
Call a tool as a tenant from issuer-b
A JWT from Customer B's IdP -- different
iss(https://issuer-b.example.com), different JWKS -- is validated the same way. Withaud: https://hangar.example.com,tenant_id: tenant:bandagentsin itsrolesclaim:curl -s http://localhost:8000/mcp \ -H "Authorization: Bearer $ISSUER_B_JWT" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' \ | sed -n 's/^data: //p' | jq '.result.tools[].name'Expected output for
tenant:b(allow_list ofcharge,refund):"charge" "refund" "hangar_whoami"One front door, two authorization servers, two tenants -- each resolved to its own member-scope policy from the same back end. A token from issuer-b that names
tenant_id: tenant:ais refused with401:tenant:ais not in issuer-b'sallowed_tenants. -
Reject a token from an unknown issuer (fail-closed)
Mint a structurally valid JWT from an issuer you never onboarded -- say
https://rogue.example.com-- and try to use it.curl -s -i http://localhost:8000/mcp \ -H "Authorization: Bearer $ROGUE_JWT" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}' \ | grep -i "^HTTP\|Untrusted"Expected output:
HTTP/1.1 401 Unauthorized {"error":"authentication_failed","message":"Untrusted JWT issuer","details":{"auth_method":"jwt"}}The request is rejected with a
401(never a500) before the token reaches any signature validator -- theissclaim is not in the trust list, so there is no JWKS to even check it against. The same fate awaits a token with a missing, empty, or non-stringiss. Critically, the error does not enumerate which issuers are trusted: an attacker probing the front door learns nothing about your trust list.
What Just Happened
auth.oidc.issuers replaces the single-issuer model with an ordered trust list.
It takes precedence over the legacy top-level auth.oidc.issuer, so once a list
is present Hangar trusts exactly those entries and nothing else. Each entry
carries its own jwks_uri, which is how two completely independent IdPs
(Keycloak, Auth0) can be validated side by side without sharing keys.
Validation is routed by the iss claim. When a bearer token arrives, Hangar
first reads iss and looks it up in the trust list. If iss is missing, empty,
not a string, or not a configured issuer, the request fails closed with a 401
and the token never reaches a signature or audience check -- that is what makes
step 6 a clean rejection rather than a crash. The set of trusted issuers is
deliberately kept out of the error response.
The audience binding is the audit story. Because auth.oidc.resource_uri is set
to https://hangar.example.com, every token's aud claim is validated against
that single URI regardless of each issuer entry's own audience (RFC 8707
resource indicators). The value you publish in the RFC 9728 metadata as
resource is therefore the exact value you enforce as aud. A token Customer A
minted for some other API -- even from a trusted issuer -- will not be accepted
here, and the discovery document, the WWW-Authenticate challenge, and the
enforced audience all name the same resource URI. Remove resource_uri and
validation falls back to each issuer's configured audience instead.
Per-issuer claim mappings inherit from the top level. issuer-b overrides
groups_claim: roles, but it omits tenant_claim, so it inherits
tenant_claim: tenant_id from auth.oidc. Both issuers therefore resolve the
tenant from tenant_id into CallerIdentity.tenant_id, which the front-door
access resolver uses to apply the member-scope policy -- exactly as in
Recipe 16.
allowed_tenants binds each issuer to the tenants it may name. Without it,
issuer-b could sign a token whose tenant_id is tenant:a, and Hangar would
treat the caller as tenant a. With it (core 2.34.2 and later), such a token is
refused with 401, as is a token from a bound issuer that names no tenant.
With an entry left unbound, boot logs oidc_issuers_not_bound_to_tenants once,
naming the open issuers. The binding
covers tenant-scoped roles only: a role granted at scope global to a subject
or group:<name> reaches a token with that subject or group from either
issuer, so grant OIDC users and groups tenant:<id> roles in a recipe like
this one.
Hangar remains a resource server throughout: it validates these JWTs, it does not issue them, mint refresh tokens, or perform dynamic client registration.
Key Config Reference
| Key | Type | Default | Description |
|---|---|---|---|
tool_access.mode | string | egress | Topology mode: egress or front_door |
auth.oidc.enabled | bool | false | Enable OIDC/JWT validation |
auth.oidc.resource_uri | string | "" | Public URI advertised as resource; also enforced as JWT aud when set |
auth.oidc.issuers | list | [] | Multi-issuer trust entries; overrides legacy auth.oidc.issuer |
auth.oidc.issuers[].issuer | string | -- | Trusted iss value matched against the token |
auth.oidc.issuers[].audience | string | -- | Expected aud for this issuer; ignored while resource_uri is set |
auth.oidc.issuers[].jwks_uri | string | -- | JWKS endpoint used to verify this issuer's signatures |
auth.oidc.issuers[].allowed_tenants | list | -- | (2.34.2) Tenants this issuer's tokens may name; a token naming another tenant, or none, is refused with 401 |
auth.role_assignments[].scope | string | global | tenant:<tenant id> confines the grant to that tenant. Use it with more than one issuer |
auth.oidc.tenant_claim | string | tenant_id | JWT claim mapped to tenant_id; inherited by issuer entries that omit it |
What's Next
For the full OIDC configuration schema, claim mappings, and multi-issuer trust semantics, see the Authentication & Authorization guide. For the front-door topology model, fail-closed access resolution, and the RFC 9728 / RFC 8707 discovery endpoints, see the Front-Door Mode guide.