This article builds on our previous post, where we introduced the core concepts of token exchange and its role in secure authentication. Here, we delve into a practical application, demonstrating how to leverage Okta and ToolHive to facilitate token exchange for authenticating an MCP server with a GraphQL API.
Environment
This demo mimics a (hopefully!) real world example where we run an API service and we want to expose it with an MCP server. The back end API requires a token with aud=backend and scopes=[backend-api:read].
"Aud" (audience) in a token specifies the intended recipient of the token, indicating which service or application is meant to consume it. "Scopes" define the specific permissions or access rights granted by the token, detailing what actions the token holder is authorized to perform. Only tokens having the expected audience and the expected scopes authorize the caller to use the service.
We don’t want to expose the back end service directly to the AI client, but only through the MCP server. We also want to maintain a clean audit trail showing us who accessed what.
The MCP server requires a token with aud=mcpserver and scopes=mcp:tools:call.
Both the API service and the MCP server are part of the same Okta realm, but we’ll use different Authorization Servers to ensure that both the token the MCP server receives and the token use different audiences.
We’ll simulate the whole flow as a developer connecting to this setup by adding the MCP server to VSCode and calling the tools it provides.
It should be noted that in this example, we’ll be using an Apollo-based GraphQL service as the backend API service and the existing Apollo MCP server, but the same setup applies to any kind of API services as long as they both use OAuth tokens from the same realm as the authentication mechanism.
In order to follow along, you can clone the Apollo GraphQL service from a demo repository.
Okta setup
I’ve used the Okta integrator setup to prepare this demo and therefore the instructions cover the whole setup from the ground up including creating the Authorization Servers. This is likely not needed or needs to be adjusted in a real world environment.
Authorization Servers
To logically separate the MCP server from the back end API service, we’ll configure two Okta Authorization servers - one for the MCP server and client and the other for the backend server.
Create the Authorization Servers and then the following scopes:
- mcpserver AS mcp:tools:call
- backend AS backend-api:read
Trust between authorization servers
In order to enable token exchange between two authorization servers - the one that issues tokens for access to the MCP server and the one that issues tokens for accessing the back end, we need to establish trust between the two.
Go to the back end AS and down at the settings tab, add the mcpserver AS as trusted:
Applications
We’ll set up two Applications:
- A VSCode client to authenticate to the MCP server. We create a client directly to avoid Dynamic Client registration. This will be an OIDC application with a client ID and a secret. It is important to match the Redirect URIs that VSCode uses. Set the Redirect URIs to http://127.0.0.1:33418 and https://vscode.dev/redirect
- A toolhive client that will perform the Token Exchange. This is an API Services type in Okta lingo. To create the application, go to:
- Applications -> Create App Integration and select API Services
- Name your application
- In the application page, navigate to the General Settings page and uncheck the “Require Demonstrating Proof of Possession” header as this is not yet supported by ToolHive
- Check the Token Exchange grant
Policies
In order for applications to authenticate, we need to include them in policies, otherwise Okta will not issue tokens to the clients. We’ll define two policies: One that allows the MCP Client (VSCode) to request tokens with mcp:tools:call and another one that allows the token exchange by the ToolHive process.
MCP client to MCP server
This policy is to be defined on the mcpserver AS side. Select “Add New Access Policy”, then “Assign to the following Clients” and select the VSCode client. When the policy is created, click “Add Rule” in the policy and in the “And the following scopes” section add both the “OpenID Connect” scopes and the mcp:tools:call scopes.
MCP server token exchange
This policy is to be defined on the back end AS side. Select “Add New Access Policy”, then “Assign to the following Clients” and select the ToolHive client. When adding the rule, don’t forget to unroll “Advanced” under the “If Grant Type Is” section and add Token Exchange. Add “backend-api:read” to the scopes.
Running the GraphQL server
Let’s clone our server locally:
git clone https://github.com/StacklokLabs/apollo-mcp-auth-demo
Next, let’s configure the IDP settings in the .env file:
cp .env.example .env
vim .env
Using my Okta integrator account, the .env file looks as follows:
# Okta Configuration
# Your Okta domain (e.g., dev-123456.okta.com)
OKTA_DOMAIN=integrator-3683736.okta.com
# Your Okta issuer URL (authorization server)
# For default authorization server: https://your-domain.okta.com/oauth2/default
# For custom authorization server: https://your-domain.okta.com/oauth2/{authServerId}
OKTA_ISSUER=https://integrator-3683736.okta.com/oauth2/auswdh3wurjeJ62La697
# JWT Validation Configuration
# Expected audience in JWT tokens (space-separated if multiple)
OKTA_AUDIENCE=backend
# Required scopes in JWT tokens (space-separated)
REQUIRED_SCOPES=backend-api:read
# Authentication Configuration
# Set to 'true' to require valid tokens for all requests (recommended)
# Set to 'false' to disable authentication requirement (for testing)
REQUIRE_AUTH=true
# Server Configuration
PORT=4000
Now we’re ready to start the server:
npm install
npm start
Running ToolHive
In our testing, we’re using the already existing Apollo MCP server with no modifications - all the heavy lifting is done by ToolHive. The Apollo MCP server is merely configured to accept the downstream authentication token in the Authorization: Bearer HTTP header and forward it to the external API.
The MCP server configuration can be found in the mcp-server-data directory in the demo repository.
Because the unmodified MCP server also validates the incoming tokens, we need to set the transport.auth.servers attribute in the config file to the back end Authorization server:
vim mcp-server-data/apollo-mcp-config.yaml
...
transport:
type: sse
port: 8000
auth:
servers:
- https://integrator-3683736.okta.com/oauth2/auswdh3wurjeJ62La697
...
Now we can run the server with:
thv run \
--debug \
--foreground \
--transport streamable-http \
--name apollo \
--target-port 8000 \
--proxy-port 8000 \
--volume $(pwd)/mcp-server-data/apollo-mcp-config.yaml:/config.yaml \
--volume $(pwd)/mcp-server-data:/data \
--oidc-audience mcpserver \
--resource-url http://localhost:8000/mcp \
--oidc-issuer https://integrator-3683736.okta.com/oauth2/ausw8f1ut6X0WMjZN697 \
--oidc-jwks-url https://integrator-3683736.okta.com/oauth2/ausw8f1ut6X0WMjZN697/v1/keys \
--token-exchange-audience backend \
--token-exchange-client-id 0oawdgw7krVBSwzIx697 \
--token-exchange-client-secret O2zqVb-evhKgfBOD-PRVDs5HFyCXAnRZAwxAtQOH9oGt72aBrLBiwEVlyyTengj9 \
--token-exchange-scopes backend-api:read \
--token-exchange-url https://integrator-3683736.okta.com/oauth2/auswdh3wurjeJ62La697/v1/token \
apollo-mcp-server -- /config.yaml
Let’s unpack the parameters:
--oidc-audience mcpserver - When the OIDC token from VSCode arrives to toolhive, then toolhive checks if the token’s aud field matches this value and rejects the connection otherwise
--resource-url http://localhost:9090/mcp - Setting the resource explicitly helps VSCode discover the proper Protected Resource Metadata Endpoint as per the MCP specification and in effect points VSCode to the Okta instance. Typically not needed in e.g. Kubernetes environments where the service name can be used
--oidc-issuer https://integrator-3683736.okta.com/oauth2/ausw8f1ut6X0WMjZN697 - This is the issuer of the mcpserver Authorization Server (see the first screenshot of the document)
--oidc-jwks-url https://integrator-3683736.okta.com/oauth2/ausw8f1ut6X0WMjZN697/v1/keys - The JWKS endpoint of the mcpserver Authorization Server
--token-exchange-audience 'backend' - We want ToolHive to take the incoming tokens and exchange them for tokens with audience of “backend”
--token-exchange-client-id 0oawdgw7krVBSwzIx697 - The Client ID of the “ToolHive client”, the one who has assigned the token exchange policy to itself
--token-exchange-client-secret O2zqVb-evhKgfBOD-PRVDs5HFyCXAnRZAwxAtQOH9oGt72aBrLBiwEVlyyTengj9 - the client secret of the ToolHive client. Outside demos, please use the --token-exchange-client-secret-file switch instead, or the TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET environment variable.
--token-exchange-scopes 'backend-api:read' - The scopes we request for the external token. Must match what’s in the policy.
--token-exchange-url [https://integrator-3683736.okta.com/oauth2/auswdh3wurjeJ62La697/v1/token](https://integrator-3683736.okta.com/oauth2/auswdh3wurjeJ62La697/v1/token) - the token endpoint of the back end Authorization Server.
Note that the example above uses thv run, but it’s equally possible to use the token exchange from thv proxy which can then also provide authentication to the MCP server:
thv proxy demo-mcp-server \
--target-uri http://localhost:8091 \
--port 3000 \
--remote-auth \
--remote-auth-client-id 0oawdhc2mlgHOwNvW697 \
--remote-auth-client-secret Ag0Zj6ALuxxqascP6KJ-CA4uCRcOLmIKtQeR_o3ClGgxMxx0zcgZYYtg-TmHF6U- \
--remote-auth-issuer https://integrator-3683736.okta.com/oauth2/ausw8f1ut6X0WMjZN697 \
--remote-auth-scopes 'mcp:tools:call,openid,email' \
--token-exchange-audience 'backend' \
--token-exchange-client-id 0oawdgw7krVBSwzIx697 \
--token-exchange-client-secret O2zqVb-evhKgfBOD-PRVDs5HFyCXAnRZAwxAtQOH9oGt72aBrLBiwEVlyyTengj9 \
--token-exchange-scopes 'backend-api:read' \
--token-exchange-url https://integrator-3683736.okta.com/oauth2/auswdh3wurjeJ62La697/v1/token
Authentication from VSCode and putting it all together
Once the server is running, it should automatically appear in the list of the configured MCP servers in VSCode. Clicking Start will prompt authentication against Okta. The first time, you’ll be prompted to enter the client ID and secret as well. Once Okta authenticates, VSCode receives the token, uses it to authenticate to the MCP server (toolhive) which exchanges the token which enables calling the back end API.
Past the initial setup on the IDP side, authentication and authorization to the MCP server fronted by ToolHive and by extension the back end service is seamless and allows partition access to the back end services as well as provides a cleaner audit trail.
As the last step, we can invoke one of the MCP tools to verify the setup end-to-end:
As seen on the screenshot above, the GetCountry tool of the Apollo server was called and returned a reply! If we check the logs of the API server we ran earlier we also see details of the token that was validated:
This token has different audience than the one passed to the ToolHive - if you recall the thv run parameters, they specified, through the --oidc-audience mcpserver argument that the tokens must set the aud claim to mcpserver while the token that arrived to the back end API has audience backend. Looking closely at the issuer, we also see that the token was issued by the back end Authorization Server, while the tokens issued to authenticate to ToolHive were issued by the mcpserver Authorization Server. This shows that the token exchange works correctly. In the next section, we’ll illustrate for completeness’ sake how the tokens look exactly and how the whole flow works.
The token exchange under the hood
The flow is described in the Mermaid diagram below.
The client authenticates to the toolhive which exposes the interface and endpoints as the MCP standard describes. The toolhive authentication middleware verifies the token was issued by the expected IDP and has the expected audience. After authentication, the token is then passed to the Token Exchange middleware which contacts the IDP and exchanges the token meant for the MCP server for the token meant for the external service.
The token issued to the client might look like this (simplified):
{
"iss": https://idp.example.com/oauth2/default",
"aud": "mcp-server",
"scp": [
"backend-mcp:tools:call",
"backend-mcp:tools:list",
],
"sub": "user@example.com",
}
While the exchanged token would have different scopes and a different audience, allowing the MCP server to authenticate to the back end service:
{
"iss": https://idp.example.com/oauth2/default",
"aud": "backend-server",
"scp": [
"backend-api:read",
],
"sub": "user@example.com",
}
This exchanged token is then injected into the Authorization: Bearer HTTP header and passed on to the actual MCP server running under Toolhive. The MCP server can then use the token.
Summary and benefits
By leveraging token exchange, ToolHive enables MCP servers to authenticate to third-party APIs in a secure, efficient, and tenant-aware way. MCP servers receive properly scoped, short-lived access tokens instead of embedding long-lived secrets or bespoke authentication logic. Each API call made upstream can be attributed to the individual user identity rather than a generic service account, making audit trails clearer and more meaningful.
References
https://modelcontextprotocol.io/docs/tutorials/security/authorization
https://developer.okta.com/docs/guides/set-up-token-exchange/main/










Top comments (0)