Skip to main content

Set up Cedar authorization in Kubernetes

This guide shows you how to attach Cedar authorization policies to your MCP servers in Kubernetes. Authorization works alongside any of the authentication approaches in Authentication and authorization: once a client is authenticated, Cedar policies decide what it's allowed to do. When using the embedded authorization server, Cedar policies can also evaluate upstream identity provider claims such as group membership. See Upstream identity provider claims for details.

For the Cedar policy language itself, including syntax, entity types, and policy-writing patterns, see Cedar policies. This guide covers the Kubernetes-specific mechanics of attaching those policies to a workload.

Prerequisites

You'll need an MCPServer, MCPRemoteProxy, or VirtualMCPServer with authentication already configured (see Authentication and authorization).

Ways to provide Cedar policies

You can provide Cedar policies to an MCPServer in three ways:

  • ConfigMap (authzConfig.type: configMap): store policies in a separate ConfigMap and reference it from the MCPServer. This keeps policies decoupled from the server spec and lets you share one policy set across multiple servers. Prefer this for larger or shared policy sets.
  • Inline (authzConfig.type: inline): define policies directly in the MCPServer spec. This is the simplest option for small, server-specific policy sets, with no separate resource to manage.
  • Shared MCPAuthzConfig (authzConfigRef): reference a separate MCPAuthzConfig resource that holds the policies. The same MCPAuthzConfig can be referenced from multiple MCPServer, MCPRemoteProxy, and VirtualMCPServer resources, and the operator blocks deletion while references exist. Prefer this for shared policy sets that you also want to manage as first-class Kubernetes resources.

All three approaches use the same Cedar policy language. authzConfig and authzConfigRef are mutually exclusive at admission.

Step 1: Create authorization configuration

Create a JSON or YAML file with Cedar policies. This example demonstrates several policy patterns:

  • Allow everyone to use the weather tool
  • Restrict the admin_tool to a specific user (alice123)
  • Role-based access: only users with the "premium" role can call any tool
  • Attribute-based: allow the calculator tool only for add/subtract operations

Here's an example in JSON format:

{
"version": "1.0",
"type": "cedarv1",
"cedar": {
"policies": [
"permit(principal, action == Action::\"call_tool\", resource == Tool::\"weather\");",
"permit(principal == Client::\"alice123\", action == Action::\"call_tool\", resource == Tool::\"admin_tool\");",
"permit(principal, action == Action::\"call_tool\", resource) when { principal.claim_roles.contains(\"premium\") };",
"permit(principal, action == Action::\"call_tool\", resource == Tool::\"calculator\") when { resource.arg_operation == \"add\" || resource.arg_operation == \"subtract\" };"
],
"entities_json": "[]",
"group_claim_name": "",
"role_claim_name": ""
}
}

Set group_claim_name to extract group membership from a custom JWT claim, and role_claim_name to extract role membership from a separate claim (for example, Entra ID roles). Leave both empty to use the default claim resolution order.

You can also define custom resource attributes in entities_json for per-tool ownership or sensitivity labels.

tip

For more policy examples and advanced usage, see Cedar policies.

Step 2: Create a ConfigMap with policies

Store your authorization configuration in a ConfigMap:

authz-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: authz-config
namespace: toolhive-system
data:
authz-config.json: |
{
"version": "1.0",
"type": "cedarv1",
"cedar": {
"policies": [
"permit(principal, action == Action::\"call_tool\", resource == Tool::\"weather\");",
"permit(principal == Client::\"alice123\", action == Action::\"call_tool\", resource == Tool::\"admin_tool\");",
"permit(principal, action == Action::\"call_tool\", resource) when { principal.claim_roles.contains(\"premium\") };"
],
"entities_json": "[]"
}
}
kubectl apply -f authz-configmap.yaml

Step 3: Update MCPServer to use authorization

Reference the ConfigMap from your MCPServer resource:

mcp-server-with-authz.yaml
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPOIDCConfig
metadata:
name: k8s-sa-authz-oidc
namespace: toolhive-system
spec:
type: kubernetesServiceAccount
kubernetesServiceAccount:
serviceAccount: 'mcp-client'
namespace: 'client-apps'
---
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPServer
metadata:
name: weather-server-with-authz
namespace: toolhive-system
spec:
image: ghcr.io/stackloklabs/weather-mcp/server
transport: sse
proxyPort: 8080
# Authentication configuration
oidcConfigRef:
name: k8s-sa-authz-oidc
audience: 'toolhive'
# Authorization configuration
authzConfig:
type: configMap
configMap:
name: authz-config
key: authz-config.json
resources:
limits:
cpu: '100m'
memory: '128Mi'
requests:
cpu: '50m'
memory: '64Mi'
kubectl apply -f mcp-server-with-authz.yaml

Share policies across resources with MCPAuthzConfig

To reuse the same policy set across multiple MCPServer, MCPRemoteProxy, or VirtualMCPServer resources, define it once as an MCPAuthzConfig and reference it by name. The operator validates the policy bundle once, tracks the references in MCPAuthzConfig.status.referencingWorkloads, and blocks deletion while any workload still references it.

mcp-authz-shared.yaml
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPAuthzConfig
metadata:
name: shared-cedar-policies
namespace: toolhive-system
spec:
type: cedarv1
config:
policies:
- 'permit(principal, action == Action::"call_tool", resource ==
Tool::"weather");'
- 'permit(principal, action == Action::"call_tool", resource) when {
principal.claim_roles.contains("premium") };'
entities_json: '[]'
---
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPServer
metadata:
name: weather-server-shared-authz
namespace: toolhive-system
spec:
image: ghcr.io/stackloklabs/weather-mcp/server
transport: sse
proxyPort: 8080
oidcConfigRef:
name: k8s-sa-authz-oidc
audience: 'toolhive'
# Reference a shared MCPAuthzConfig instead of inline policies
authzConfigRef:
name: shared-cedar-policies

authzConfig and authzConfigRef are mutually exclusive and the CRD rejects manifests that set both. The referenced MCPAuthzConfig must exist in the same namespace as the workload.

VirtualMCPServer support

VirtualMCPServer only accepts cedarv1 MCPAuthzConfig resources via spec.incomingAuth.authzConfigRef. Referencing an MCPAuthzConfig with a different type (for example, httpv1) fails reconciliation with a clear condition message because the vMCP runtime authorization middleware is Cedar-only.

Test your setup

  1. Make requests that should be permitted by your policies
  2. Make requests that should be denied
  3. Check the proxy logs to see authorization decisions

Next steps

Troubleshooting

Clients receive 401 Unauthorized

A 401 means token validation failed before the request reached your policy layer. Work through these checks in order:

  1. Check the token itself. Decode the JWT (for example, paste it into jwt.io - never use production tokens). Verify the iss claim matches your configured issuer URL exactly (including trailing paths like /oauth2/default), the aud claim matches the configured audience, and exp is in the future.
  2. Check the request. The Authorization header must be Authorization: Bearer <TOKEN> with no extra whitespace or quoting.
  3. Check provider reachability. ToolHive first fetches the OIDC discovery document from ${issuerUrl}/.well-known/openid-configuration, then fetches signing keys from the jwks_uri listed in that document (or from the explicit jwksUrl if you've configured one). Both URLs must be reachable. In Kubernetes, that means egress from the proxy pod; with the CLI, it means egress from your machine.

To inspect the proxy logs for the specific validation error:

ToolHive CLI
# Container logs
thv logs <SERVER_NAME>

# Proxy logs (auth runs here)
thv logs <SERVER_NAME> --proxy

# Follow live
thv logs <SERVER_NAME> --proxy --follow
Kubernetes
# Operator logs (reconciliation errors)
kubectl logs -n toolhive-system deployment/toolhive-operator

# Proxy logs for a specific MCPServer
kubectl logs -n toolhive-system \
-l app.kubernetes.io/managed-by=toolhive,app.kubernetes.io/name=<SERVER_NAME> \
-c proxy
Authenticated requests are denied (403)

A 403 means authentication succeeded but Cedar policy evaluation rejected the request. ToolHive uses default-deny: anything not explicitly permitted is denied.

  1. Match principal, action, and resource exactly. Cedar is case-sensitive. Tool::"GetIssue" is not the same as Tool::"get_issue".
  2. Check policy conditions. If your policy has a when { ... } clause, make sure the JWT actually contains the claim you reference (with the claim_ prefix). For example, principal.claim_groups.contains("writers") requires a groups claim on the token.
  3. Use has for tool annotations. Policies that read resource.readOnlyHint, destructiveHint, idempotentHint, or openWorldHint must guard the access with resource has <attr> first. Reading a missing attribute throws an evaluation error that ToolHive treats as a deny.
  4. Confirm group claims arrive. If you use principal in THVGroup::"<name>", decode the token and verify the configured group claim (groups, roles, cognito:groups, or whatever you set in group_claim_name) is present and contains the expected values.

For the full attribute and entity reference, see Authorization policy reference.

ConfigMap mounting issues:

  • Verify the ConfigMap exists: kubectl get configmap -n toolhive-system authz-config
  • Check the ConfigMap content: kubectl get configmap authz-config -n toolhive-system -o yaml

Authorization ConfigMap not resolved:

When spec.authzConfig.type: configMap, the controller pre-validates the referenced ConfigMap and surfaces failures on the AuthConfigured condition with one of two reasons:

  • AuthzConfigMapNotFound: the ConfigMap does not exist in the namespace. Create it before reconciling, or correct the name/namespace reference.
  • AuthzConfigMapInvalid: the ConfigMap exists but the payload is missing the configured key, is empty, has malformed YAML or JSON, or fails Cedar validation. Inspect the payload and the Cedar configuration shape.
kubectl describe mcpserver -n toolhive-system <name>