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).
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
- Entra
- SPIFFE/SPIRE
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:
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
An Entra Application ID URI is fixed when you register it and can't reference a
per-deployment in-cluster hostname that doesn't exist yet. Register a stable
Identifier URI on the Entra app ahead of time and widen acceptedAudiences to
accept it instead of the real token endpoint:
trustedIssuers:
- name: entra-issuer
issuerUrl: 'https://sts.windows.net/<TENANT_ID>/'
jwksUrl: 'https://login.windows.net/common/discovery/keys'
inboundGrants:
jwtBearer:
issuerPolicies:
- issuerRef: entra-issuer
maxAssertionAge: 70m
# Entra's Identifier URI is fixed at registration time and can't equal
# this deployment's real (dynamic) token endpoint, so accept it instead.
acceptedAudiences:
- 'https://<tenant>.onmicrosoft.com/toolhive-as-jwtbearer/oauth/token'
subjectBindings:
- subject: '<APP2_OBJECT_ID>'
allowedResources:
- https://vmcp.example.com/mcp-resource
A SPIRE-attested workload needs no shared secret anywhere in the flow. Its
identity comes entirely from attestation (for example, "this pod, in this
namespace, with this service account, in this trust domain"), and it requests
ToolHive's real token endpoint as its JWT-SVID's audience directly, so no
acceptedAudiences override is needed. The one thing that does need a
workaround: SPIRE's OIDC discovery provider only serves its JWKS over HTTPS with
a SPIRE-internal CA-issued certificate, and jwksUrl has no CA-bundle option.
Mirror the same keys over plain HTTP instead (fine inside the cluster network;
not how you'd expose this across a real trust boundary):
trustedIssuers:
- name: spire-issuer
issuerUrl: 'https://oidc-discovery.<trust-domain>'
jwksUrl: 'http://spire-jwks-mirror.<namespace>.svc.cluster.local:8000/keys.json'
insecureAllowHTTP: true
allowPrivateIPs: true
inboundGrants:
jwtBearer:
issuerPolicies:
- issuerRef: spire-issuer
maxAssertionAge: 70m
subjectBindings:
- subject: 'spiffe://<trust-domain>/ns/<namespace>/sa/<service-account>'
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 todayEmbeddedAuthServerConfig.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:
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:
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
- Delegate agent identity with token exchange to record which user an agent is acting for
- Configure the vMCP embedded authorization server
for the rest of the
authServerConfigsurface
Related information
- Secretless delegate clients with
private_key_jwt- a different mechanism that's easy to confuse with this one, since both come from RFC 7523. That one is how an already-registered client proves its identity instead of using a secret; the JWT-bearer grant covered on this page has no client at all. - Embedded authorization server for the
OAuth flow, token storage, and the
actclaim - MCPExternalAuthConfig reference for the complete field list
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.
| Error | Likely 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_target | The 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. |