-
Notifications
You must be signed in to change notification settings - Fork 93
feat(ai-gateway): How-to for validating MCP tokens locally with JWK verification #4839
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tomek-labuk
wants to merge
10
commits into
main
Choose a base branch
from
jwk-mcp
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+322
−0
Open
Changes from 7 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
0619131
WIP
tomek-labuk cbd18bc
add tags
tomek-labuk 68f7949
Update how-to
tomek-labuk 722ed34
update faq
tomek-labuk 61ee4bc
appease vale
tomek-labuk 1782fe4
Merge branch 'main' into jwk-mcp
tomek-labuk 5ee6b9e
move file
tomek-labuk 0b4e258
Update app/_how-tos/mcp/validate-mcp-tokens-with-jwk.md
tomek-labuk f8d3faf
feat(mcp): add automated_tests: false to JWK validation how-to
Copilot b5f0fee
Merge branch 'main' into jwk-mcp
tomek-labuk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
8 changes: 8 additions & 0 deletions
8
app/_data/entity_examples/gateway/routes/weather-jwk-route.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| name: weather-jwk-route | ||
| paths: | ||
| - /api/weather | ||
| service: | ||
| name: weather-jwk-service | ||
| protocols: | ||
| - http | ||
| - https |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| name: weather-jwk | ||
| paths: | ||
| - /weather/mcp | ||
| - /.well-known/oauth-protected-resource/weather/mcp | ||
| service: | ||
| name: weather-jwk-service | ||
| protocols: | ||
| - http | ||
| - https |
2 changes: 2 additions & 0 deletions
2
app/_data/entity_examples/gateway/services/weather-jwk-service.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| name: weather-jwk-service | ||
| url: https://api.weatherapi.com/v1 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,302 @@ | ||
| --- | ||
| title: Validate MCP tokens locally with JWK verification | ||
| content_type: how_to | ||
| description: "Configure the AI MCP OAuth2 plugin to validate MCP access tokens locally using the authorization server's published JWK Set instead of token introspection" | ||
| products: | ||
| - gateway | ||
| - ai-gateway | ||
| works_on: | ||
| - on-prem | ||
| - konnect | ||
| min_version: | ||
| gateway: '3.14' | ||
| plugins: | ||
| - ai-mcp-oauth2 | ||
| - ai-mcp-proxy | ||
| entities: | ||
|
tomek-labuk marked this conversation as resolved.
|
||
| - service | ||
| - route | ||
| - plugin | ||
| permalink: /mcp/validate-mcp-tokens-with-jwk/ | ||
| tags: | ||
| - ai | ||
| - mcp | ||
| - authentication | ||
| tldr: | ||
| q: "How do I validate MCP tokens locally without calling an introspection endpoint?" | ||
| a: "Set `jwks_endpoint` in the AI MCP OAuth2 plugin config. Kong fetches the authorization server's public keys, caches them, and validates each incoming JWT locally without a per-request round-trip." | ||
| tools: | ||
| - deck | ||
| related_resources: | ||
| - text: AI MCP OAuth2 plugin | ||
| url: /plugins/ai-mcp-oauth2/ | ||
| - text: AI MCP Proxy plugin | ||
| url: /plugins/ai-mcp-proxy/ | ||
| - text: Secure MCP tools with OAuth2 and Okta (introspection) | ||
| url: /mcp/secure-mcp-tools-with-oauth2-and-okta/ | ||
| prereqs: | ||
| inline: | ||
| - title: WeatherAPI | ||
| icon_url: /assets/icons/gateway.svg | ||
| content: | | ||
| 1. Go to [WeatherAPI](https://www.weatherapi.com/). | ||
| 1. Sign up for a free account. | ||
| 1. Navigate to [your dashboard](https://www.weatherapi.com/my/) and copy your API key. | ||
| 1. Export your API key: | ||
|
|
||
| ```sh | ||
| export DECK_WEATHERAPI_API_KEY='your-weatherapi-api-key' | ||
| ``` | ||
| - title: Set up Keycloak | ||
| icon_url: /assets/icons/gateway.svg | ||
| content: | | ||
| This guide uses [Keycloak](http://www.keycloak.org/) as the authorization server. Keycloak publishes a JWKS endpoint that Kong uses to validate tokens locally. | ||
|
|
||
| #### Install and run Keycloak | ||
|
|
||
| Run the Keycloak Docker image on the same network as Kong Gateway: | ||
|
|
||
| ```sh | ||
| docker run -p 127.0.0.1:8080:8080 \ | ||
| --name keycloak \ | ||
| --network kong-quickstart-net \ | ||
| -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ | ||
| -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ | ||
| -e KC_HOSTNAME=http://localhost:8080 \ | ||
| quay.io/keycloak/keycloak start-dev | ||
| ``` | ||
|
|
||
| Export the Keycloak endpoints. `DECK_KEYCLOAK_ISSUER` uses `localhost` (reachable from your machine). `DECK_KEYCLOAK_JWKS_ENDPOINT` uses the container name `keycloak` (reachable from Kong Gateway over the shared Docker network): | ||
|
|
||
| ```sh | ||
| export DECK_KEYCLOAK_ISSUER='http://localhost:8080/realms/master' | ||
| export DECK_KEYCLOAK_JWKS_ENDPOINT='http://keycloak:8080/realms/master/protocol/openid-connect/certs' | ||
| export KEYCLOAK_HOST='localhost' | ||
| ``` | ||
|
|
||
| #### Create the MCP client | ||
|
|
||
| 1. Open the Keycloak admin console at `http://localhost:8080/admin/master/console/`. | ||
| 1. In the sidebar, open **Clients**, then click **Create client**. | ||
| 1. **General settings**: Client type: **OpenID Connect**, Client ID: `mcp-gateway`. | ||
| 1. **Capability config**: Toggle **Client authentication** to **on**. Check **Service accounts roles** (this enables the `client_credentials` grant). | ||
| 1. Click **Save**. | ||
| 1. Open the **Credentials** tab, copy the **Client Secret**, and export it: | ||
|
|
||
| ```sh | ||
| export DECK_MCP_CLIENT_ID='mcp-gateway' | ||
| export DECK_MCP_CLIENT_SECRET='YOUR-CLIENT-SECRET' | ||
| ``` | ||
|
|
||
| #### Configure the audience claim | ||
|
|
||
| Keycloak does not include a custom audience in tokens by default. Add a client scope mapper so that tokens issued by `mcp-gateway` include the MCP resource URL in the `aud` claim. This lets Kong validate the audience without relaxing validation. | ||
|
|
||
| 1. In the sidebar, open **Client scopes**, then click **Create client scope**. | ||
| 1. Name: `mcp-audience`. Click **Save**. | ||
| 1. Open the **Mappers** tab, click **Configure a new mapper**, and select **Audience**. | ||
| 1. Name: `mcp-resource-audience`. | ||
| 1. **Included Custom Audience**: `http://localhost:8000/weather/mcp` | ||
| 1. Toggle **Add to access token** to **on**. | ||
| 1. Click **Save**. | ||
| 1. In the sidebar, open **Clients**, click `mcp-gateway`, then click the **Client scopes** tab. | ||
| 1. Click **Add client scope**, check `mcp-audience`, click **Add** and set the scope as **Default**. | ||
| entities: | ||
| services: | ||
| - weather-jwk-service | ||
| routes: | ||
| - weather-jwk-route | ||
| - weather-jwk | ||
| cleanup: | ||
| inline: | ||
| - title: Clean up Konnect environment | ||
| include_content: cleanup/platform/konnect | ||
| icon_url: /assets/icons/gateway.svg | ||
| - title: Destroy the {{site.base_gateway}} container | ||
| include_content: cleanup/products/gateway | ||
| icon_url: /assets/icons/gateway.svg | ||
| faqs: | ||
| - q: When should I use JWK validation instead of token introspection? | ||
| a: | | ||
| Use JWK validation when your authorization server publishes a JWKS endpoint and issues JWTs. JWK validation avoids per-request round-trips to the authorization server, since Kong validates tokens locally after fetching and caching the public keys. | ||
|
|
||
| Use token introspection when the authorization server issues opaque tokens (not JWTs), or when you need real-time token revocation checks on every request. Introspection requires `client_id`, `client_secret`, and `introspection_endpoint`. | ||
|
|
||
| - q: Do I still need `client_id` and `client_secret` in the plugin config with JWK validation? | ||
| a: | | ||
| No. The `client_id` and `client_secret` fields in the AI MCP OAuth2 plugin config are used for token introspection, where Kong calls the authorization server's introspection endpoint as a confidential client. With JWK validation, Kong validates tokens locally and does not need these credentials. | ||
|
|
||
| --- | ||
|
|
||
| ## Configure the AI MCP Proxy tools | ||
|
|
||
| Configure the [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) in `conversion-only` mode on the `weather-jwk-route` Route. This instance converts the WeatherAPI REST endpoints into MCP tool definitions. The `weather-jwk-tools` tag lets the listener instance discover and aggregate these tools. | ||
|
|
||
| {% entity_examples %} | ||
| entities: | ||
| plugins: | ||
| - name: ai-mcp-proxy | ||
| route: weather-jwk-route | ||
| tags: | ||
| - weather-jwk-tools | ||
| - jwk | ||
| config: | ||
| mode: conversion-only | ||
| tools: | ||
| - annotations: | ||
| title: Realtime API | ||
| description: Returns current weather data as a JSON object for a given location. | ||
| method: GET | ||
| path: current.json | ||
| query: | ||
| key: | ||
| - ${weatherapi_key} | ||
| parameters: | ||
| - name: q | ||
| in: query | ||
| description: Pass US Zipcode, UK Postcode, Canada Postalcode, IP address, Latitude/Longitude (decimal degree) or city name. | ||
| required: true | ||
| type: string | ||
| variables: | ||
| weatherapi_key: | ||
| value: $WEATHERAPI_API_KEY | ||
| {% endentity_examples %} | ||
|
|
||
| ## Configure the AI MCP Proxy listener | ||
|
|
||
| Configure a second [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) instance in `listener` mode on the `weather-jwk` Route. This instance aggregates tools tagged `weather-jwk-tools` and serves them over the MCP protocol to connected clients. | ||
|
|
||
| {% entity_examples %} | ||
| entities: | ||
| plugins: | ||
| - name: ai-mcp-proxy | ||
| route: weather-jwk | ||
| tags: | ||
| - jwk | ||
| config: | ||
| mode: listener | ||
| server: | ||
| tag: weather-jwk-tools | ||
| timeout: 45000 | ||
| logging: | ||
| log_statistics: true | ||
| log_payloads: false | ||
| max_request_body_size: 32768 | ||
| {% endentity_examples %} | ||
|
|
||
| ## Configure the AI MCP OAuth2 plugin with JWK validation | ||
|
|
||
| Configure the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) on the `weather-jwk` Route with `jwks_endpoint` pointing at Keycloak's certificate endpoint. Kong fetches the public keys, caches them for the duration set in `jwks_cache_ttl`, and validates each incoming JWT locally. | ||
|
|
||
| {% entity_examples %} | ||
| entities: | ||
| plugins: | ||
| - name: ai-mcp-oauth2 | ||
| tags: | ||
| - jwk | ||
|
tomek-labuk marked this conversation as resolved.
Outdated
|
||
| route: weather-jwk | ||
| config: | ||
| authorization_servers: | ||
| - ${keycloak_issuer} | ||
| jwks_endpoint: ${keycloak_jwks_endpoint} | ||
| jwks_cache_ttl: 3600 | ||
| resource: http://localhost:8000/weather/mcp | ||
| metadata_endpoint: "/.well-known/oauth-protected-resource/weather/mcp" | ||
| variables: | ||
| keycloak_issuer: | ||
| value: $KEYCLOAK_ISSUER | ||
| keycloak_jwks_endpoint: | ||
| value: $KEYCLOAK_JWKS_ENDPOINT | ||
| {% endentity_examples %} | ||
|
|
||
| Notice what's absent compared to the [introspection-based config](/mcp/secure-mcp-tools-with-oauth2-and-okta/): no `client_id`, no `client_secret`, no `introspection_endpoint`, and no `insecure_relaxed_audience_validation`. Kong validates tokens locally using the public keys from the JWKS endpoint, and audience validation works because Keycloak includes the resource URL in the `aud` claim. | ||
|
|
||
| `jwks_cache_ttl` controls how long Kong caches the fetched keys, in seconds. The default is `3600` (one hour). If an incoming token's `kid` does not match any cached key (for example, after a key rotation), the plugin re-fetches the JWKS and retries once before returning `401`. | ||
|
|
||
| {:.info} | ||
| > If you omit `jwks_endpoint`, the plugin attempts to discover the JWKS URL from the authorization server's metadata (for example, from `/.well-known/openid-configuration`). Set `jwks_endpoint` explicitly when the authorization server is reachable at a different hostname from Kong's perspective, as is the case with Docker networking in this guide. | ||
|
|
||
| ## Validate | ||
|
|
||
| ### Get a token from Keycloak | ||
|
|
||
| Obtain a JWT from Keycloak using the `client_credentials` grant: | ||
|
|
||
| ```sh | ||
| MCP_TOKEN=$(curl -s -X POST \ | ||
| http://$KEYCLOAK_HOST:8080/realms/master/protocol/openid-connect/token \ | ||
| -d "grant_type=client_credentials" \ | ||
| -d "client_id=$DECK_MCP_CLIENT_ID" \ | ||
| -d "client_secret=$DECK_MCP_CLIENT_SECRET" | jq -r .access_token) && echo $MCP_TOKEN | ||
| ``` | ||
|
|
||
| ### Confirm unauthenticated requests are rejected | ||
|
|
||
| Send a request without a token: | ||
|
|
||
| <!--vale off--> | ||
| {% validation request-check %} | ||
| url: /weather/mcp | ||
| status_code: 401 | ||
| method: POST | ||
| headers: | ||
| - 'Content-Type: application/json' | ||
| body: | ||
| jsonrpc: "2.0" | ||
| id: 1 | ||
| method: tools/list | ||
| params: {} | ||
| message: 401 Invalid or inactive token | ||
| {% endvalidation %} | ||
|
tomek-labuk marked this conversation as resolved.
|
||
| <!--vale on--> | ||
|
|
||
| The response returns a `401` status, confirming the plugin is enforcing authentication. | ||
|
|
||
| ### Confirm valid tokens are accepted | ||
|
|
||
| Send a request with the JWT: | ||
|
|
||
| <!--vale off--> | ||
| {% validation request-check %} | ||
| url: /weather/mcp | ||
| status_code: 200 | ||
| method: POST | ||
| headers: | ||
| - 'Accept: application/json, text/event-stream' | ||
| - 'Content-Type: application/json' | ||
| - 'Authorization: Bearer $MCP_TOKEN' | ||
| body: | ||
| jsonrpc: "2.0" | ||
| id: 1 | ||
| method: tools/list | ||
| params: {} | ||
| {% endvalidation %} | ||
| <!--vale on--> | ||
|
|
||
| A successful response returns the list of available MCP tools: | ||
|
|
||
| ```json | ||
| {"jsonrpc":"2.0","result":{"tools":[{"id":"4b3117c8-5894-4f4c-b6e7-c321911caf18","description":"Returns current weather data as a JSON object for a given location.","inputSchema":{"properties":{"query_q":{"description":"Pass US Zipcode, UK Postcode, Canada Postalcode, IP address, Latitude/Longitude (decimal degree) or city name.","type":"string"}},"required":["query_q"],"type":"object","additionalProperties":false},"name":"realtime-api","annotations":{"title":"Realtime API"}}]},"id":1} | ||
| ``` | ||
| {:.no-copy-code} | ||
|
|
||
| ### Confirm tampered tokens are rejected | ||
|
|
||
| Modify one character in the token and send the request again: | ||
|
|
||
| <!--vale off--> | ||
| {% validation request-check %} | ||
| url: /weather/mcp | ||
| status_code: 401 | ||
| method: POST | ||
| headers: | ||
| - 'Content-Type: application/json' | ||
| - 'Authorization: Bearer ${MCP_TOKEN}x' | ||
| body: | ||
| jsonrpc: "2.0" | ||
| id: 1 | ||
| method: tools/list | ||
| params: {} | ||
| message: 401 Invalid or inactive token | ||
|
tomek-labuk marked this conversation as resolved.
|
||
| {% endvalidation %} | ||
| <!--vale on--> | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.