Skip to main content

Accept workload assertions with the JWT-bearer grant

Use the RFC 7523 JWT-bearer grant to let a trusted workload exchange a signed assertion for a ToolHive token through a Virtual MCP Server (vMCP). The workload does not need a ToolHive client registration or shared secret; it proves who it is with whatever identity its platform already issued it (a cloud IAM role, a Kubernetes-native workload identity, or a service identity from your IdP).

info

The inboundGrants.jwtBearer.issuerPolicies field covered here is also available on a plain MCPServer through MCPExternalAuthConfig's embeddedAuthServer block, using the same configuration structure shown below under authServerConfig. For the MCPServer field reference, see Set up the embedded authorization server in Kubernetes.

If you're choosing between mechanisms, see the comparison table in Choose a way to get a token. The JWT-bearer grant answers "how does a workload with no registered client get a token at all?". RFC 8693 delegation answers a different question: "who is this agent acting for?"

Configure a trusted issuer

To accept an assertion, ToolHive needs an inboundGrants.jwtBearer.issuerPolicies entry naming the workload's subject and the resource it may request. These fragments nest under spec.authServerConfig. What the entry looks like depends on the identity provider:

Okta's Custom Authorization Server lets you set its audiences field to an arbitrary caller-chosen string, so you can register it as the exact ToolHive token endpoint the workload's assertion will be presented to:

VirtualMCPServer: jwtBearer issuer policy
trustedIssuers:
- name: okta-issuer
issuerUrl: 'https://<org>.okta.com/oauth2/<AUTH_SERVER_ID>'
jwksUrl: 'https://<org>.okta.com/oauth2/<AUTH_SERVER_ID>/v1/keys'
inboundGrants:
jwtBearer:
issuerPolicies:
- issuerRef: okta-issuer
maxAssertionAge: 5m
subjectBindings:
- subject: '<OKTA_SERVICE_APP_CLIENT_ID>'
allowedResources:
- https://vmcp.example.com/mcp-resource

expectedAudience, actorClaim, actorMatcher, and allowMayAct (all under inboundGrants.tokenExchange.issuerPolicies) are delegation-specific and unrelated to the JWT-bearer grant; an issuer used only for inboundGrants.jwtBearer needs none of them.

upstreamProviders is still required today

EmbeddedAuthServerConfig.upstreamProviders is optional at the CRD level when a trusted issuer with a JWT-bearer grant is configured, but the operator's reconcile-time validation doesn't yet recognize inboundGrants.jwtBearer-only configuration as satisfying that requirement (it only checks the legacy per-issuer jwtBearerGrant field). Until that's fixed, a JWT-bearer-only VirtualMCPServer needs a placeholder upstreamProviders entry. ToolHive never contacts this endpoint or uses its credentials, so any syntactically valid OAuth2 endpoint works:

Placeholder upstream (workaround)
upstreamProviders:
- name: unused
type: oauth2
oauth2Config:
authorizationEndpoint: 'https://example.invalid/authorize'
tokenEndpoint: 'https://example.invalid/token'
clientId: 'unused'
clientSecretRef:
name: unused-upstream-secret
key: client-secret
scopes:
- openid

Without it, the VirtualMCPServer fails to become Ready with: auth server requires at least one upstream unless delegate clients or a trusted issuer with JWT bearer grant is configured. Remove the placeholder once the validator is updated to normalize inboundGrants before checking.

Request a token

Once a trusted issuer is configured, the workload sends its assertion straight to /oauth/token; possession of the assertion is the only credential ToolHive checks:

POST /oauth/token
curl -s -X POST https://vmcp.example.com/oauth/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
-d "assertion=<SIGNED_ASSERTION>" \
-d "resource=https://vmcp.example.com/mcp-resource"

ToolHive mints a token for a synthetic client derived deterministically from the assertion's issuer and subject. There's no delegation and nothing to pre-register.

Next steps

Troubleshooting

Two things vary by issuer and are worth checking first if an exchange fails: whether the assertion carries a jti at all (Entra's client_credentials tokens and plain SPIRE JWT-SVIDs never include one, so ToolHive falls back to hashing the raw assertion for replay protection instead), and whether the assertion's aud needs an acceptedAudiences entry to match, per the tabs above.

ErrorLikely cause
invalid_grant: "The JWT bearer assertion issuer is not enabled for this grant."The assertion's iss doesn't match a trustedIssuers entry with an inboundGrants.jwtBearer.issuerPolicies entry configured.
invalid_grant: "The JWT bearer assertion subject is not configured for this grant."The assertion's sub has no matching entry in issuerPolicies[].subjectBindings.
invalid_targetThe resource parameter isn't in the matched subject binding's allowedResources.
invalid_grant: "The JWT bearer assertion has already been used."The assertion's replay key (its jti, or a hash of the assertion when jti is absent) was already consumed.