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.
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:
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
- 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:
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
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 pointing at any
reachable OAuth2 endpoint, with credentials that are never actually used:
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
- 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. - Delegate agent identity with token exchange for RFC 8693 delegation and the full comparison of authentication patterns
- Embedded authorization server for the
OAuth flow, token storage, and the
actclaim - Configure the vMCP embedded authorization server
for the full
authServerConfigreference - 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. |