Delegate agent identity with token exchange
When an AI agent acts on a user's behalf (reading their calendar, filing a ticket, calling a tool through a Virtual MCP Server (vMCP)), you usually want ToolHive to record both facts: who the human is, and which agent actually made the call, so audit logs and authorization policies can tell "Alice, directly" apart from "an agent, acting for Alice." RFC 8693 token-exchange delegation is how ToolHive does this: a pre-provisioned client exchanges a user's token for one that names both the user and the agent acting for them.
The fields covered here (trustedIssuers and inboundGrants.tokenExchange) are
also available on a plain MCPServer through MCPExternalAuthConfig's
embeddedAuthServer block, using the same configuration structure shown below
under authServerConfig. This page focuses on VirtualMCPServer because
delegation is primarily useful when an agent orchestrates calls across multiple
backends on a user's behalf. For the MCPServer field reference, see
Set up the embedded authorization server in Kubernetes.
Overview
If you're deciding which of ToolHive's ways to get a token fits your case, see the comparison in Choose a way to get a token.
This page covers how ToolHive mints a delegated token. If you're looking for
how ToolHive reads an act claim on an incoming token, for example because
your own IdP already performs RFC 8693 delegation upstream of ToolHive, see
Delegated identities and the act claim.
RFC 8693 delegation with a pre-provisioned delegate client
Delegation requires two pieces of configuration on authServerConfig: an
inboundGrants.tokenExchange.delegateClients entry for the agent that will
perform the exchange, and an inboundGrants.tokenExchange.issuerPolicies entry
that tells ToolHive which external issuer's tokens it will accept as a subject
token, and which actors are allowed to act on behalf of the tokens it issues.
For how to create the delegateClients entry itself (the client ID, secret,
audiences, and scopes), see
Pre-provision confidential clients for token exchange.
delegateClients is separate from Dynamic Client Registration (plain,
confidential, or
private_key_jwt). A
delegateClients entry has a clientId and secret you choose and create
yourself, never one a client obtains by registering itself. A client uses one
path or the other; issuerPolicies[].allowedDelegateClients (below) is what
authorizes either kind of client_id to actually perform an exchange, once it
has one.
Both entries are required. The issuer policy grants a delegate client permission
to accept a subject token from the external issuer; on its own it doesn't create
a usable exchange path, because RFC 8693 requires authenticated access to
/oauth/token and the delegate client is what authenticates.
This section focuses on the issuer-policy side, which authorizes delegation:
spec:
authServerConfig:
issuer: https://vmcp.example.com
# ...
trustedIssuers:
- name: entra-issuer
issuerUrl: 'https://sts.windows.net/<TENANT_ID>/'
jwksUrl: 'https://login.windows.net/<TENANT_ID>/discovery/v2.0/keys'
inboundGrants:
tokenExchange:
delegateClients:
- clientId: coding-agent
clientSecretRef:
name: coding-agent-secret
key: client-secret
scopes:
- openid
audiences:
- https://vmcp.example.com/mcp-resource
issuerPolicies:
- issuerRef: entra-issuer
expectedAudience: 'https://vmcp.example.com/mcp-resource'
# "appid" is where Microsoft Entra v1 tokens carry the calling
# application's client ID, verified against a real Entra tenant.
# Other issuers use a different claim name for the same purpose
# (for example Okta's client_credentials tokens use "cid"); check
# your issuer's own token claims, or use the literal value
# "client_id" (a sentinel, not a claim name) to read the token's
# client_id claim.
actorClaim: appid
allowedActors:
- <APP1_CLIENT_ID>
allowedDelegateClients:
- coding-agent
trustedIssuers[].name gives this issuer declaration a stable identifier that
issuerPolicies[].issuerRef binds a grant policy to; issuerUrl and jwksUrl
identify the external identity provider that minted the subject token the agent
will present (in this example, a user's Entra sign-in as a separate application,
App1). expectedAudience must match the audience already present on that
subject token. actorClaim names the claim carrying the calling application's
client ID; it defaults to azp when you leave it unset.
allowedDelegateClients is what binds an external actor to a specific ToolHive
delegate client. Without it, every confidential client holding the
token-exchange grant would be equivalent for delegation purposes. Set it to
["*"] to declare that permissiveness explicitly; the wildcard must stand
alone, and combining it with specific client IDs is rejected at admission. If
you also enable allowMayAct, set it explicitly to false rather than omitting
it, since an omitted allowMayAct combined with the wildcard is rejected by a
validation rule that can't yet distinguish "not set" from "false"
(tracked upstream).
For the complete field list, including jwksUrl, caBundleRef,
allowPrivateIPs, and the per-issuer insecureAllowHTTP, see
the MCPExternalAuthConfig schema reference.
Trust a private issuer CA
When the external issuer uses a private CA for its discovery or JWKS endpoint, store its PEM certificate in a ConfigMap and reference it from the issuer:
spec:
authServerConfig:
trustedIssuers:
- issuerUrl: 'https://<ISSUER_HOST>'
caBundleRef:
configMapRef:
name: external-issuer-ca
key: ca.crt
ToolHive uses the additional certificate only for this issuer's discovery and JWKS requests. Restrict write access to this ConfigMap because it defines the trust anchor used to validate subject tokens.
Walk through an exchange
Once configured, the agent (coding-agent) presents a user's subject token to
ToolHive's /oauth/token endpoint using its own client credentials:
curl -s -X POST https://vmcp.example.com/oauth/token \
-u "coding-agent:<CLIENT_SECRET>" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=<SUBJECT_TOKEN>" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
-d "audience=https://vmcp.example.com/mcp-resource"
SUBJECT_TOKEN is the user's own token from the external issuer. In this
example, an Entra access token issued to App1 with aud set to the backend
resource and appid set to App1's client ID. ToolHive validates that token
against the matching trustedIssuers entry, confirms App1 is an allowed
actor, and confirms coding-agent is an allowed delegate client, then issues a
delegated access token. Decoding it shows the delegation:
{
"sub": "https://sts.windows.net/<TENANT_ID>/#<user-object-id>",
"act": {
"iss": "https://vmcp.example.com",
"sub": "coding-agent",
"act": {
"iss": "https://sts.windows.net/<TENANT_ID>/",
"sub": "<APP1_CLIENT_ID>"
}
}
}
sub identifies the user (qualified with the issuer to avoid collisions across
identity providers). The outer act names the authenticated ToolHive client
that performed the exchange, in act.sub, with act.iss set to the ToolHive
issuer that minted the token. The nested act.act is the external actor
asserted by the subject token itself, the application the user actually signed
in through. This nested shape is how ToolHive represents a two-hop delegation
chain: an external application acting for a user, and a ToolHive client acting
on top of that.
Choosing a consent policy
inboundGrants.tokenExchange.issuerPolicies entries support three independent
ways to authorize delegation from an external issuer. Any one of them being
satisfied is sufficient; they aren't layered as an all-must-pass chain:
| Field | How it authorizes |
|---|---|
allowedActors | A static allowlist of actorClaim values. Use this for a fixed, known set of external applications (like App1 above). |
actorMatcher | An admin-authored CEL expression evaluated against the subject token's complete, signature-verified claims map (bound as claims). Use this when the authorization rule can't be expressed as a flat allowlist, for example matching on a claim pattern or a combination of claims. Must evaluate to a boolean; a non-boolean result denies the token at evaluation time. |
allowMayAct | Trusts a may_act claim the external issuer itself already asserts on the subject token, bypassing allowedActors and actorMatcher entirely. Defaults to false; external issuers must be opted in explicitly, since may_act shifts the consent decision to the external IdP. Doesn't apply to self-issued subject tokens. Enabling it alongside allowedDelegateClients: ["*"] is rejected at admission. |
allowedDelegateClients is a separate, always-required control: it restricts
which ToolHive clients may perform the exchange, independent of which external
actor the subject token names. allowedActors, actorMatcher, and
allowMayAct all authorize the external actor; allowedDelegateClients
authorizes the ToolHive-side client.
Secretless delegate clients with private_key_jwt
A delegateClients entry requires ToolHive to hold a shared secret for the
agent. Setting allowPrivateKeyJWTRegistration: true on authServerConfig
instead lets an agent register itself via Dynamic Client Registration (DCR)
using only a keypair it generates locally. ToolHive never issues, stores, or
transmits a secret for that client.
spec:
authServerConfig:
issuer: https://vmcp.example.com
allowPrivateKeyJWTRegistration: true
Registration is unauthenticated, the same as ordinary DCR, so enabling this lets
any caller who can reach /oauth/register register a private_key_jwt client.
If you're comparing ways to avoid provisioning a client secret at all, see also Client ID Metadata Document (CIMD), which lets a client authenticate from a hosted metadata document instead of either a shared secret or a keypair registered through DCR. CIMD doesn't involve delegation, so it's unrelated to the exchange described on this page.
Register and authenticate with a keypair
The agent generates an RSA (or EC) keypair and declares only the public half at registration:
curl -s -X POST https://vmcp.example.com/oauth/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["http://localhost:19999/callback"],
"token_endpoint_auth_method": "private_key_jwt",
"token_endpoint_auth_signing_alg": "RS256",
"grant_types": ["urn:ietf:params:oauth:grant-type:token-exchange"],
"jwks": {"keys": [{"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "agent-key", "n": "<MODULUS>", "e": "AQAB"}]}
}'
The response carries a client_id and no client_secret. The keypair
itself is the credential. To authenticate at the token endpoint, the agent signs
a client_assertion JWT with its private key (iss and sub set to its own
client_id, aud set to ToolHive's token endpoint, with a unique jti), then
presents it alongside the subject token:
curl -s -X POST https://vmcp.example.com/oauth/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=<SUBJECT_TOKEN>" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
-d "audience=https://vmcp.example.com/mcp-resource" \
-d "client_id=<DCR_CLIENT_ID>" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion=<SIGNED_ASSERTION>"
No client_secret parameter appears anywhere in this request. ToolHive
validates the client_assertion's signature against the JWKS declared at
registration and rejects a replayed client_assertion (the same jti presented
twice) on the second attempt. Everything else about the exchange, subject-token
validation against trustedIssuers, actor resolution, and the resulting act
claim, works exactly as in the pre-provisioned case above; the issued token's
act.sub is the DCR-assigned client_id instead of a statically configured
one.
Next steps
- Accept workload assertions with the JWT-bearer grant to let a workload authenticate with no registered client at all
- Read the
actclaim in Cedar policies to authorize on the delegation chain you just configured - Configure the vMCP embedded authorization server
for the rest of the
authServerConfigsurface
Related information
- Embedded authorization server for the
OAuth flow, token storage, and the
actclaim on the reading side - Backend authentication for the other backend authentication patterns
- MCPExternalAuthConfig reference for the complete field list
Troubleshooting
| Error | Likely cause |
|---|---|
invalid_client (exact hint varies by cause: wrong secret, wrong token_endpoint_auth_method, or a public client attempting a confidential-only grant) | The delegate client must be confidential. Verify inboundGrants.tokenExchange.delegateClients[].clientSecretRef is set and the client authenticated with the matching secret and method. |
invalid_grant: "The subject token does not authorize this client to act on behalf of the subject." | The subject token's may_act.sub doesn't match the actor ToolHive resolved for this request. |
invalid_grant: "This client is not authorized to exchange subject tokens from the external actor's issuer." | The authenticated client isn't in issuerPolicies[].allowedDelegateClients for the issuer that minted the subject token. |
invalid_grant: "The subject token was issued to a different client." | The subject token's own client_id claim doesn't match the authenticated delegate client, and the client isn't in allowedDelegateClients. |
invalid_request: "The subject token is invalid or could not be verified." | The subject token failed signature, issuer, or audience validation against every configured trustedIssuers entry. |
CRD rejected: issuerPolicies[]: allowedDelegateClients: Required value | An inboundGrants.tokenExchange.issuerPolicies entry is missing allowedDelegateClients. Add it. |
| CRD rejected: "delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled" | A delegate client uses a plain-HTTP issuer. Use an https:// issuer, or for local development set insecureAllowConfidentialOverLoopbackHTTP: true with a loopback issuer. |