Authorization
The Registry Server provides a claims-based authorization model that controls who can manage sources, registries, and entries. Authorization builds on top of authentication. You need OAuth authentication enabled before configuring authorization.
How authorization works
When a caller makes an API request, the server:
- Extracts the caller's claims from their JWT token
- Resolves the caller's roles based on those claims and the
authzconfiguration - Checks whether the caller's role permits the operation
- Checks whether the caller's claims satisfy the resource's claims
Claim scopes
Claims attach to three kinds of resource. Each scope controls a different part of the request:
| Scope | What its claims govern |
|---|---|
| Registry claims | Access to the registry's consumer API. A caller whose JWT doesn't satisfy them gets 403 on the whole /registry/{name} surface. |
| Entry claims | Per-entry visibility within a registry the caller can already reach. A consumer sees an entry only if their JWT satisfies both the registry's claims and the entry's claims. |
| Source claims | Admin visibility of the source, linking the source into a registry, and publishing into a managed source. Source claims never filter entries at read time. |
See Source claims for how each source type assigns claims to its entries.
Configure roles
Define roles in the auth.authz.roles section of your configuration file. Each
role maps to a list of claim rules. If a caller's JWT claims match any rule in
the list, they are granted that role.
auth:
mode: oauth
oauth:
resourceUrl: https://registry.example.com
providers:
- name: keycloak
issuerUrl: https://keycloak.example.com/realms/mcp
audience: registry-api
authz:
roles:
superAdmin:
- role: 'super-admin'
manageSources:
- org: 'acme'
role: 'admin'
manageRegistries:
- org: 'acme'
role: 'admin'
manageEntries:
- role: 'writer'
Available roles
| Role | Grants access to |
|---|---|
superAdmin | All operations; bypasses all claim checks |
manageSources | Create, update, delete, and list sources via the admin API |
manageRegistries | Create, update, delete, and list registries via the admin API |
manageEntries | Publish, delete, and manage claims on MCP server versions and skills |
Role rule matching
Each role is defined as a list of claim maps. A caller is granted the role if their JWT claims match any map in the list (OR logic). Within a single map, all key-value pairs must match (AND logic).
authz:
roles:
manageSources:
# Rule 1: any admin in the acme org
- org: 'acme'
role: 'admin'
# Rule 2: anyone with the platform-lead role
- role: 'platform-lead'
Claim values can be strings or arrays. When a JWT claim is an array (for
example, role: ["admin", "writer"]), the server checks whether any element
matches the required value. A rule with role: "admin" would match this JWT
because "admin" is one of the array elements.
Super-admin role
The superAdmin role bypasses all claim checks across the entire server. A
super-admin can:
- Access any registry regardless of its claims
- See all entries regardless of source or entry claims
- Manage any source or registry, even those with claims outside their JWT
- Publish and delete entries without claim validation
Use this role sparingly and only for platform operators who need unrestricted access.
Configure claims on sources and registries
Claims are key-value pairs attached to sources and registries in your configuration file. They act as access boundaries: only callers whose JWT claims satisfy the resource's claims can access it.
Source claims
For synced sources (Git, API, File), claims on a source are inherited by all entries during sync. Every MCP server or skill ingested from that source carries the source's claims.
For Kubernetes and managed sources, source claims are not inherited by
entries. Kubernetes entries get claims from the
authz-claims annotation
on each CRD, and managed source entries get claims from the publish request
payload.
A managed source's own claims decide who can publish into it. To publish, a
caller needs the manageEntries role, and their JWT must satisfy the source's
existing claims using the visibility rule. A managed
source with no claims can only be published to by a super-admin.
This check controls publishing, not entry visibility. Give the source a claim
that matches everyone who should be able to publish there, such as
org: "acme". Each published entry's own claims control who can read it.
Source claims also decide who can see and manage the source through the admin API, and whether the source can be added to a registry.
sources:
- name: platform-tools
git:
repository: https://github.com/acme/platform-tools.git
branch: main
path: registry.json
syncPolicy:
interval: '30m'
claims:
org: 'acme'
team: 'platform'
- name: data-tools
git:
repository: https://github.com/acme/data-tools.git
branch: main
path: registry.json
syncPolicy:
interval: '30m'
claims:
org: 'acme'
team: 'data'
With this configuration:
- Entries from
platform-toolsare visible only to callers withorg: "acme"andteam: "platform"in their JWT - Entries from
data-toolsare visible only to callers withorg: "acme"andteam: "data"in their JWT - A super-admin sees all entries regardless of claims
Registry claims
Claims on a registry act as an access gate for the consumer API. Before returning any data from a registry's endpoints, the server checks that the caller's JWT claims satisfy the registry's claims.
registries:
- name: platform
sources: ['platform-tools']
claims:
org: 'acme'
team: 'platform'
- name: public
sources: ['community-tools']
# No claims - accessible to all authenticated users
A caller who requests GET /registry/platform/v0.1/servers must have JWT claims
that include org: "acme" and team: "platform". Otherwise, the server returns
403 Forbidden.
Claim matching rules
The server compares caller claims against a resource's claims with two rules that differ only in how they handle array-valued claims. Every check does an AND across keys (the caller must satisfy every key present on the resource), and both rules default-deny on unlabeled resources.
| Rule | Within-array test | Used for |
|---|---|---|
| Visibility | OR - the caller must share any one array value | Reads and access gates: list and get on sources, registries, and entries; the registry access gate on /registry/{name}; publishing into a managed source; delete on sources, registries, and entry versions. |
| Subset | AND - the caller must hold every array value | Writes: create or update a source or registry; set incoming entry claims during publish; update the claims on a published entry. Applied to the incoming claims in the request, not to the resource's existing tags. |
For scalar (string) claims the two rules behave identically. The difference only shows up on array values.
For example, a resource tagged team: ["platform", "data"] is an allow-list:
under the visibility rule, a caller whose JWT has team: "platform" can see and
delete it. Under the subset rule, the same caller could not create or update
a resource tagged that way, because they don't hold both platform and data
in their own JWT. This split prevents a caller from stamping a resource with
wider visibility than their own identity while keeping shared resources visible
to every eligible team.
Examples on scalar claims:
| Resource claims | Caller JWT claims | Result |
|---|---|---|
{org: "acme"} | {org: "acme", team: "platform"} | Allowed |
{org: "acme", team: "platform"} | {org: "acme"} | Denied |
{} (no claims) | {org: "acme"} | Denied (default-deny on unlabeled) |
{org: "acme"} | {org: "contoso"} | Denied |
Registry Server v1.4.x treated array-valued claims as AND-within-array for every
check, so a resource tagged team: ["platform", "data"] was invisible to a
caller who held only one of the team values. From v1.5.0, reads and access gates
use the visibility (OR) rule instead, so callers who share any of the tagged
values can see and delete the resource. Writes still enforce the subset (AND)
rule to prevent visibility escalation. If you relied on AND-within-array to keep
an array-tagged resource hidden from callers who held only one value, retag the
resource with the specific set you want to require.
Unlabeled resources
When auth.authz is configured, sources, registries, and entries with no claims
are treated as default-deny: invisible to every authenticated caller except
a super-admin.
Anonymous mode and auth-only mode bypass the gate entirely, so unlabeled resources remain accessible there.
To make a resource reachable under full authorization, attach claims to it:
- Sources and registries: set the
claimsfield on the source or registry in your configuration file. - Entries from synced sources: set claims on the source so they're inherited during sync.
- Entries on Kubernetes sources: use the
authz-claimsannotation on the CRD. - Entries on managed sources: include
claimsin the publish payload, or callPUT /v1/entries/{type}/{name}/claimsfrom a super-admin.
Releases before v1.4.1 treated an unlabeled resource as visible to every
authenticated caller, but only on single-resource paths like
GET /v1/sources/{name}. List endpoints already filtered them out, so the same
row could appear via one path and not another.
After upgrading to v1.4.1 or later with auth.authz configured, those unlabeled
rows become invisible to every non-super-admin caller. To restore access, a
super-admin can tag them in place via the per-entry claims endpoint, or
operators can add a tenant-wide claim to the source so synced entries inherit it
on the next sync.
Claims on published entries
Before publishing, your JWT must satisfy the managed source's existing claims. See Source claims. The server then enforces three rules on the entry's claims:
- Claims are required when authentication is enabled. Publishing without
claims, or with an empty
claimsobject, against an authenticated endpoint returns400 Bad Request. Without claims, the entry would be invisible to every non-super-admin caller, so the server rejects the request up front. - Publish claims must be a subset of the publisher's JWT claims. Every
claim key in the publish request must exist in the publisher's JWT with a
matching value. For example, if your JWT has
{org: "acme", team: "platform"}, you can publish entries with{org: "acme"}or{org: "acme", team: "platform"}, but not{org: "contoso"}(a value your JWT doesn't have). - Subsequent versions must have the same claims as the first. Once you publish the first version of an entry with specific claims, all future versions must carry the exact same claims. If the first version had none, subsequent versions must also have none. This prevents accidentally narrowing or broadening visibility across versions.
curl -X POST \
https://registry.example.com/v1/entries \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"server": {
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.acme/my-server",
"description": "Team-scoped MCP server",
"version": "1.0.0"
},
"claims": {
"org": "acme",
"team": "platform"
}
}'
See the Registry API reference for the full
server payload schema, including packages, remotes, and _meta fields.
Read and update claims on an existing entry
Use GET /v1/entries/{type}/{name}/claims to fetch the claims on a previously
published entry, and PUT /v1/entries/{type}/{name}/claims to change them. The
type path parameter is either server or skill, and the endpoints require
the manageEntries role. Because entry names contain a slash separating
namespace from server name, URL-encode the slash as %2F so the path is treated
as a single {name} path parameter.
curl -X GET \
"https://registry.example.com/v1/entries/server/io.github.acme%2Fmy-server/claims" \
-H "Authorization: Bearer $TOKEN"
The response envelope is always a JSON object, even when the entry has no claims:
{
"claims": {
"org": "acme",
"team": "platform"
}
}
curl -X PUT \
"https://registry.example.com/v1/entries/server/io.github.acme%2Fmy-server/claims" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"claims": {
"org": "acme",
"team": "platform"
}
}'
Pass an empty claims object ({"claims": {}}) to clear claims entirely.
Successful updates return 204 No Content.
Both endpoints apply the standard claim matching rules to the caller's JWT:
- The GET returns
403 Forbiddenunless the caller can see the entry under the visibility rule (or is a super-admin). - The PUT requires that the caller can see the existing entry (visibility) and that the new claims are a subset of the caller's JWT (subset), so a caller can't broaden an entry's visibility past their own identity.
Both endpoints are scoped to managed-source entries. Entries synced from a Git,
API, file, or Kubernetes source get their claims from upstream (the source
manifest, or the toolhive.stacklok.dev/authz-claims annotation for
Kubernetes), and every sync overwrites them, so any API write would be
ephemeral. To inspect the claims on synced entries, use
/v1/sources/{name}/entries or /v1/registries/{name}/entries.
Admin API claim scoping
When authorization is enabled, the admin API endpoints for managing sources and registries are also scoped by claims:
- List sources/registries: Only returns resources the caller can see under the visibility rule.
- Get source/registry by name: Returns
404 Not Found(not403) when the caller can't see the resource. This prevents information disclosure about resources the caller cannot access. - List source/registry entries:
GET /v1/sources/{name}/entriesandGET /v1/registries/{name}/entriesreturn the raw entries (servers and skills with versions and claims) in a source or registry. These endpoints follow the same claim scoping: the parent source or registry must be accessible to the caller. - Create source/registry: The request claims must be a subset of the caller's JWT claims (subset rule).
- Update source/registry: The caller must be able to see the existing resource (visibility rule) and the incoming claims must be a subset of the caller's JWT (subset rule).
- Delete source/registry: The caller must be able to see the resource (visibility rule). On shared resources tagged with an array of allowed values, any caller who can see the resource can delete it, so list, get, and delete now agree.
See Claim matching rules for how visibility and subset differ on array-valued claims.
Auth-only mode
When OAuth authentication is enabled but the auth.authz block is omitted from
your configuration, the server runs in auth-only mode. In this mode:
- Callers must still authenticate with a valid JWT token
- All claims-based filtering is disabled. Every authenticated caller sees all entries regardless of source or registry claims
- Role checks are pass-throughs, so every authenticated caller can reach every endpoint
GET /v1/mereports every defined role for authenticated callers, reflecting that authorization isn't being enforced- The server logs a warning at startup:
Authorization not configured, per-entry claims filtering disabled (auth-only mode)
Auth-only mode is useful when you need identity verification without multi-tenant visibility controls, for example, a single-team deployment where all authenticated users should have the same access.
To enable full authorization, add the auth.authz block with
role definitions.
Anonymous mode
When authentication is set to anonymous, all authorization checks are
bypassed. There are no JWT claims to validate, so all sources, registries, and
entries are accessible without restriction. This is suitable for development and
testing environments only.
Check your identity and permissions
Use the GET /v1/me endpoint to verify your authenticated identity and resolved
roles:
curl -H "Authorization: Bearer $TOKEN" \
https://registry.example.com/v1/me
{
"subject": "user@example.com",
"roles": ["manageSources", "manageEntries"]
}
This is useful for debugging authorization issues. You can confirm which roles
your JWT grants and whether the expected claims are present. The endpoint
returns 401 Unauthorized in anonymous mode since there is no identity to
report.
Complete example
This example shows a multi-team setup with full RBAC and claims-based scoping:
sources:
- name: platform-tools
git:
repository: https://github.com/acme/platform-tools.git
branch: main
path: registry.json
syncPolicy:
interval: '30m'
claims:
org: 'acme'
team: 'platform'
- name: data-tools
git:
repository: https://github.com/acme/data-tools.git
branch: main
path: registry.json
syncPolicy:
interval: '30m'
claims:
org: 'acme'
team: 'data'
- name: shared
managed: {}
claims:
org: 'acme'
registries:
- name: platform
sources: ['platform-tools', 'shared']
claims:
org: 'acme'
team: 'platform'
- name: data
sources: ['data-tools', 'shared']
claims:
org: 'acme'
team: 'data'
auth:
mode: oauth
oauth:
resourceUrl: https://registry.example.com
providers:
- name: keycloak
issuerUrl: https://keycloak.example.com/realms/mcp
audience: registry-api
authz:
roles:
superAdmin:
- role: 'super-admin'
manageSources:
- org: 'acme'
role: 'admin'
manageRegistries:
- org: 'acme'
role: 'admin'
manageEntries:
- role: 'writer'
With this configuration:
- Platform team members (JWT with
org: "acme",team: "platform") can access theplatformregistry and see entries fromplatform-toolsandshared. - Data team members (JWT with
org: "acme",team: "data") can access thedataregistry and see entries fromdata-toolsandshared. - Writers in the
acmeorg (JWT withorg: "acme",role: "writer") can publish to thesharedmanaged source because their JWT satisfies the source'sorg: "acme"claim. Publishing checks that source claim, so without it only super-admins could publish to the source (default-deny). - Admins (JWT with
org: "acme",role: "admin") can manage sources and registries within theacmeorg. - Super-admins (JWT with
role: "super-admin") can access and manage everything.
Every entry published to the shared source must carry claims (publishing
without claims is rejected when authentication is enabled), and those claims
must be a subset of the publisher's JWT. To narrow visibility within a registry,
publish with team-scoped claims like {org: "acme", team: "platform"} so only
that team's registry surfaces the entry. See
Claims on published entries for the full rules.
Next steps
- Configure audit logging to record who performs which operations
Related information
- Registry Server introduction - architecture and features overview