-
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
base: main
Are you sure you want to change the base?
Changes from all commits
0619131
cbd18bc
68f7949
722ed34
61ee4bc
1782fe4
5ee6b9e
0b4e258
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| name: weather-jwk-service | ||
| url: https://api.weatherapi.com/v1 |
| 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: | ||
|
Comment on lines
+8
to
+16
|
||
| - 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 | ||
| 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--> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The linked issue’s DoD calls out adding a reference to this new JWK-validation guide on the MCP landing page, but this PR only adds the how-to and entity examples; please add/update the MCP landing page entry so the new guide is discoverable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's okay for now.