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 trustedIssuers[].jwtBearerGrant 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 a trustedIssuers[].jwtBearerGrant 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: jwtBearerGrant policy
spec:
authServerConfig:
issuer: https://vmcp.example.com
trustedIssuers:
- issuerUrl: 'https://<org>.okta.com/oauth2/<AUTH_SERVER_ID>'
jwksUrl: 'https://<org>.okta.com/oauth2/<AUTH_SERVER_ID>/v1/keys'
jwtBearerGrant:
maxAssertionAge: 5m
subjectBindings:
- subject: '<OKTA_SERVICE_APP_CLIENT_ID>'
allowedResources:
- https://vmcp.example.com/mcp-resource

expectedAudience, actorClaim, actorMatcher, and allowMayAct on a trustedIssuers entry are all delegation-specific and unrelated to jwtBearerGrant; an issuer used only for the JWT-bearer grant needs none of them.

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 jwtBearerGrant configured.
invalid_grant: "The JWT bearer assertion subject is not configured for this grant."The assertion's sub has no matching entry in jwtBearerGrant.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.

Next steps