Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
9 changes: 9 additions & 0 deletions app/_data/entity_examples/gateway/routes/weather-jwk.yaml
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
302 changes: 302 additions & 0 deletions app/_how-tos/mcp/validate-mcp-tokens-with-jwk.md
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"
Comment on lines +1 to +4
Copy link

Copilot AI Apr 15, 2026

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

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.

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
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This how-to will be picked up by the automated how-to test runner (products include gateway/ai-gateway) but it depends on an external Keycloak container and manual setup steps, so it should either add an automated prereq that starts/configures Keycloak or set automated_tests: false in frontmatter to avoid CI failures.

Copilot uses AI. Check for mistakes.
- 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 %}
Comment thread
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
Comment thread
tomek-labuk marked this conversation as resolved.
{% endvalidation %}
<!--vale on-->
Loading