Skip to main content

Accept workload assertions with the JWT-bearer grant

An AI agent isn't a person who can type a password, and it usually isn't a long-lived service account either. It needs to prove who it is every time it runs, using whatever identity its platform already issued it (a cloud IAM role, a Kubernetes-native workload identity, or a service identity from your IdP). The JWT-bearer grant lets an agent use that identity directly to call a tool through vMCP: it presents a signed assertion from its identity provider straight to ToolHive's token endpoint, and ToolHive exchanges it for a ToolHive token, with no ToolHive client registration, no shared secret, nothing for an admin to provision ahead of time beyond trusting the issuer itself.

info

The inboundGrants.jwtBearer.issuerPolicies field covered here is also available on a plain MCPServer through MCPExternalAuthConfig's embeddedAuthServer block, using the same shape 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 Delegate agent identity with token exchange. 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?"

How the grant works

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. To accept the assertion in the first place, ToolHive needs an inboundGrants.jwtBearer.issuerPolicies entry naming the workload's subject and the resource it may request. What that 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
spec:
authServerConfig:
issuer: https://vmcp.example.com
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 pointing at any reachable OAuth2 endpoint, with credentials that are never actually used:

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.

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.