kong-mcp-oauth2 — Unified MCP OAuth front door (Kong + Signet)
繁體中文版本請見 README.zh-TW.md
Hands-on macOS walkthrough against a real Signet: HANDS-ON.zh-TW.md (繁體中文)
Expose the demo publicly over ngrok for remote MCP clients (MCP Inspector, claude.ai): NGROK.zh-TW.md (繁體中文)
mcp-oauth2 is a Kong go-pdk plugin — built
following Kong's Develop Go plugins
guide — that puts one OAuth front door in front of every MCP server. Internal MCP services already sit
behind Kong; this plugin makes them stop
accepting hand-written PATs and instead require a Signet-issued OAuth access
token — validated locally with RS256 + JWKS, then forwarded to the MCP
backend.
Architecture at a glance
The diagram above walks the whole handshake end to end: A · Discovery (Kong
advertises the flow — steps ② ③), B · OAuth (the client drives Auth Code +
PKCE against Signet while Kong fetches the JWKS), and C · Verified access
(Kong verifies the RS256 token offline at step ⑤, then forwards upstream with
X-MCP-Subject / X-MCP-Scope). Editable source:
architecture.excalidraw — open it at
excalidraw.com to tweak. The two Mermaid diagrams
below are the lightweight, GitHub-rendered version of the same flow.
graph LR
client["MCP client<br/>(runs PKCE itself)"]
kong["Kong<br/>+ mcp-oauth2 plugin"]
authgate["Signet<br/>Authorization Server"]
mcp["MCP server(s)<br/>gitea / sentry"]
client <-->|"MCP requests<br/>+ 401 challenge / PRM"| kong
kong -->|"forward + X-MCP-Subject / X-MCP-Scope"| mcp
client -->|"Auth Code + PKCE<br/>/authorize · /token"| authgate
kong -.->|"JWKS fetch<br/>(cached, auto-rotated)"| authgate
Kong does not run the OAuth flow. It only advertises where the flow is
(steps ②③) and verifies the token that comes back (step ⑤). The MCP client
runs Auth Code + PKCE against Signet by itself. One plugin config covers all MCP
servers — attach it to each service with a different resource_path.
The handshake
This is the MCP authorization handshake (the 2025-06 MCP spec on top of RFC 9728
Protected Resource Metadata and RFC 6750 bearer tokens). The numbers map to the
main.go comments:
sequenceDiagram
participant C as MCP client
participant K as Kong + mcp-oauth2
participant A as Signet
participant M as MCP server
C->>K: GET /mcp/server (no token)
K-->>C: ② 401 + WWW-Authenticate:<br/>Bearer resource_metadata="‹PRM URL›"
C->>K: GET /.well-known/oauth-protected-resource/mcp/server
K-->>C: ③ 200 Protected Resource Metadata<br/>(authorization_servers, scopes)
C->>A: Auth Code + PKCE (/authorize, /token)
A-->>C: RS256 access token
K-)A: fetch JWKS (cached / auto-rotated)
C->>K: GET /mcp/server + Bearer ‹jwt›
Note over K: ⑤ verify sig(JWKS) + exp (+ iss unless skip_issuer_check)<br/>+ type=access (unless skip_type_check) + scope<br/>+ aud (unless skip_audience_check)
K->>M: forward + X-MCP-Subject / X-MCP-Scope
M-->>K: 200
K-->>C: 200
Why RS256 + JWKS (not HS256)
- No shared secret on the gateway. With HS256 the gateway would have to hold Signet's signing secret — putting a forge-anything key on the edge. With RS256 + JWKS, Kong only ever sees the public key.
- Zero-touch key rotation. Rotate keys in Signet's JWKS; Kong picks them up automatically (keyfunc background refresh). No Kong config change.
- Alg-confusion is blocked. The plugin pins accepted algorithms to
RS256/RS384/RS512and refusesHS*. This defeats the classic forgery where an attacker signs HS256 using the RSA public key as the HMAC secret. (Validation matrix row 5, last item, tests exactly this.)
The verification engine is MicahParks/keyfunc,
which handles JWKS fetch, in-memory cache, background rotation, and rate-limited
refetch on an unknown kid — the parts that are easy to get wrong by hand in Lua.
Configuration reference
One plugin instance per MCP resource. See kong.yml for full examples.
Only tokens with type=access are accepted; Signet refresh tokens (same key,
iss, aud, and scope, differing only by type and a longer exp) are
rejected with 401 invalid_token. Set skip_type_check: true only when the
authorization server is known not to emit a type claim.
go-pdk schemas can't mark fields required, so the three required fields are validated on the first request instead — a missing one fails every request with
500 server_errorand a critical log line, not a silent misbehavior.Routing gotcha. Each Kong route must match both
resource_pathand its PRM path (/.well-known/oauth-protected-resource+resource_path). Otherwise Kong has no route to hand the client's step ③ lookup to and the plugin never serves the metadata. See thepaths:lists inkong.yml.Cross-resource replay warning (if you set
skip_audience_check: true). With the check skipped,audis not validated, so the only thing distinguishing one MCP resource from another isscope. A token minted with multiple scopes (e.g.mcp:gitea mcp:sentry) is accepted at every resource whose scope it carries, and because the raw bearer is forwarded upstream unchanged, a backend that receives it can replay it against a sibling resource. This is why the check is on by default; if you skip it to debug token issuance, remove the flag before treating resources as isolated.Migrating from ≤ 0.4.x (
require_audienceremoved).audenforcement is now the default, and the old opt-in field is gone: a declarative config that still containsrequire_audienceis rejected by Kong's schema validation at load time (a deliberate loud failure, not a silent behavior change).
require_audience: true→ delete the line (the default now covers it).require_audience: false→ replace withskip_audience_check: true— but first check whether your tokens can simply be minted with the rightaud(RFC 8707resourceparameter, see the preflight section below).If tokens without a matching
audstart getting401after the upgrade, therejected tokenline in Kong's log names the audience mismatch, andskip_audience_check: trueis the temporary escape hatch.
1. Build the plugin
go-pdk plugins are ordinary executables that speak the pluginserver RPC protocol
— no cgo, no .so. A full go build needs network access for the go-pdk
protobuf transitive deps; run it from the repo root:
go mod tidy && go build -o mcp-oauth2 .
2. Wire it into Kong
Register the plugin and point the pluginserver at the binary (env vars, shown in
docker-compose.yml):
KONG_PLUGINS=bundled,mcp-oauth2
KONG_PLUGINSERVER_NAMES=mcp-oauth2
KONG_PLUGINSERVER_MCP_OAUTH2_START_CMD=/usr/local/bin/mcp-oauth2
KONG_PLUGINSERVER_MCP_OAUTH2_QUERY_CMD=/usr/local/bin/mcp-oauth2 -dump
3. Run the demo stack
docker compose up --build
This starts DB-less Kong (proxy on :8000; the unauthenticated admin API is
bound to container-loopback and not published — see docker-compose.yml) with
two stub MCP upstreams. Edit kong.yml so issuer / gateway_origin /
jwks_uri point at your real Signet before expecting tokens to validate.
4. Validation matrix
After docker compose up, exercise the handshake. Replace $GW with
http://localhost:8000 for the demo (or your gateway_origin).
Rows 1–2 work against the stub demo as shipped. Rows 3–5b need real tokens: point
issuer/jwks_uriinkong.ymlat a Signet first (with the placeholder config they fail with503 temporarily_unavailable, sinceauth.example.comhas no JWKS to fetch). Becauseaudis enforced by default, the tokens for rows 3–5a must be bound to the resource — request them withresource=<gateway_origin + resource_path>(RFC 8707). Row 5c is the exception — an HS256 forgery is rejected with401 invalid_tokenbefore any JWKS fetch (the alg is pinned first), so it returns401even against the placeholder config.
Rows 5b and 5c are the security-critical ones — run them before going live.
Signet-side preflight
Before this works end-to-end, confirm three things on Signet (decode a real
access token, not just the id_token):
- JWKS resolves.
GET <issuer>/.well-known/openid-configuration→ itsjwks_urireturns a non-emptykeysarray. - Access tokens are RS256. Decode an actual access token; its header
algisRS256(notHS256) and itskidmatches a key in the JWKS. Signet's default is oftenJWT_SECRET(HS256) — make sure you've moved access tokens (not onlyid_token) to asymmetric signing. - Issuer matches. The token's
issequals the plugin'sissuerconfig, byte-for-byte (mind the trailing slash). audbinds to the resource.audis enforced by default, so every token must be requested with RFC 8707 resource binding: add<gateway_origin + resource_path>(e.g.https://gw.example.com/mcp/server) to the OAuth client'sallowed_resourcesin Signet (an empty allowlist is deny-all and the token endpoint answersinvalid_target), then sendresource=<that URL>on the token request. Decode the token and confirmaudequals the plugin's expected value exactly.
Operational notes
-
Set
KONG_LOG_LEVEL: "info"to see 401 diagnostics. The plugin logs rejection reasons (rejected token,non-access token,control chars) at Kong'sinfolevel viakong.Log.Info. Kong's default log level isnotice, which is higher thaninfo, so these messages are invisible unless you lower it. Both shipped compose files already setKONG_LOG_LEVEL: "info". Filter with:docker compose logs -f kong | grep -E "rejected token|non-access token|control chars|JWKS|plugin misconfigured" -
Auto-discovery adds the metadata endpoint to the availability chain. With
jwks_uriempty, the first token (and one refresh per hour) also depends on the issuer's AS metadata endpoint; a cold-cache discovery failure is answered503and retried on the next request, while a failed hourly refresh keeps serving the last discoveredjwks_uri. -
JWKS endpoint must be highly available. If the initial fetch fails, token requests get
503 temporarily_unavailable(not401, so clients don't re-run OAuth) and it is retried on the next request — a failed initial fetch is never cached. Fetch waits are capped at 10s and run under a per-URI lock, so a slow Signet can't stall traffic for other resources. Caveat: once keys are cached, a token whosekidis unknown returns401 invalid_token(offline validation can't tell "key rotated in mid-outage" from "forged kid"), and an hourly refresh that pulls a JWKS containing one malformed key can drop the cached keys until a clean refresh. Keep the JWKS valid and overlap keys generously during rotation. -
Browser-based MCP clients need CORS. A CORS preflight (
OPTIONS, noAuthorization) is answered with the401challenge; put Kong'scorsplugin on the route if web-hosted clients must reach the gateway. -
Overlap keys during rotation. Keep the old and new keys in the JWKS together for a window so in-flight tokens aren't killed mid-rotation.
-
Keep access-token TTLs short. Like any offline validation, a revoked token stays valid until its
exp— minutes, not hours. -
The bearer token is forwarded upstream unchanged. Kong adds the
X-MCP-*identity headers (Subject/Scope/Issuer/Audience/Client/Token-Id/Expires) but does not strip or exchange theAuthorizationheader, so each MCP backend receives a live, replayable token. Trust your MCP backends accordingly, and leave the defaultaudenforcement on (don't setskip_audience_check) so a backend can't reuse a token against a sibling resource — it can still replay it against the same resource untilexp.