From API Key to OAuth Token: Implementing JWT Authorization Grant with Kong and Keycloak
A practical follow-up to “Two OAuth 2.0 Specs You Should Know About” — this one is the actual build.
From API Key to OAuth Token: Implementing JWT Authorization Grant with Kong and Keycloak
A practical follow-up to “Two OAuth 2.0 Specs You Should Know About” — this one is the actual build.
In the previous article I described how Token Exchange is the wrong tool for an API gateway that wants to mint OAuth tokens on behalf of consumers who authenticate with an API key, and that JWT Bearer Authorization Grant (RFC 7523) is the right one. That article was the what and why. This one is the how.
We’ll wire up a real API gateway built on Kong (with the Datakit plugin) talking to a Keycloak authorization server. The end result: a client sends an API key, and the upstream service receives a properly signed user-scoped OAuth access token. No custom headers, no shared per-consumer secrets, no naked impersonation.
Heads up before you read further: the Datakit plugin used in this article ships only with Kong Gateway Enterprise — it is not part of the open-source distribution. The overall pattern (sign a JWT assertion in the gateway, exchange it via RFC 7523, cache the access token, attach it upstream) is portable to any gateway you can extend with a custom Lua plugin or a sidecar, but if you’re on OSS Kong you will be writing that glue yourself rather than declaring it in YAML.
The Architecture in One Picture
Here is what we are building, end to end:

JWT Authorization Grant Flow
Three things have to be true for this to work:
- Kong knows who the consumer is. The
key-authplugin resolves the API key to a Kong consumer record (with a knownusername). - Kong can prove it is Kong. A private RS256 key, kept in a secrets vault, never leaves the gateway’s infrastructure. Keycloak knows the matching public key.
- Keycloak is willing to mint a user-scoped token on Kong’s word, but only for users that have been explicitly linked to “the gateway” as a federated identity provider.
That last point is the one most blog posts skip. Identity assertion is not “Kong gets to claim anyone.” It is “Kong gets to claim users that are pre-authorized to be assertable by Kong.” Set that up wrong and Keycloak returns invalid_grant.
Register Kong as an Identity Provider
Keycloak treats the gateway as an external identity provider — one that signs assertions about its users. As of Keycloak 26.6, “JWT Authorization Grant” is its own provider type in the admin console (no longer a checkbox on the OIDC provider).
In the Keycloak admin console, in the target realm:
- Identity providers → Add provider → JWT Authorization Grant
- Fill in the form:
- Alias:
kong-gateway(this name is referenced everywhere downstream — pick it once) - Enabled: On
- Issuer:
https://kong.example.com/datakit— a stable string identifier. It is not a URL Keycloak fetches; just keep it identical across environments so your gateway template stays env-agnostic. - Use JWKS URL: Off (we are using a single static key)
- Validating public key: paste the gateway’s RS256 public key in PEM form
- Assertion signature algorithm: RS256
- Allow assertion reuse: Off — assertions are one-shot, replay protection by
jti - Max allowed assertion expiration: 10 minutes (we will mint with 5 in Kong)
- Allowed clock skew: 30 seconds
- Save.
The IdP itself does nothing until a client is configured to use it. That is the next step.
Wire the Client to Accept Kong’s Assertions
Pick an existing OAuth client in the same realm — in this example it is kong-introspection-client, the same client Kong already uses for token introspection. In its Settings tab → Capability config:
- Toggle JWT Authorization Grant Enabled → On
- The form reveals Allowed Identity Providers for JWT Authorization Grant — add
kong-gateway - Toggle Standard Token Exchange Enabled → Off
That last toggle is worth dwelling on. If you reached this implementation by way of misreading Token Exchange (as I did), you may be tempted to leave it on as a fallback. Don’t. Leaving Standard Token Exchange enabled enlarges the attack surface for no gain, and it tempts future maintainers to mix the two flows.
Under the hood, these toggles set three client attributes:
oauth2.jwt.authorization.grant.enabled = "true"
oauth2.jwt.authorization.grant.idp = "kong-gateway"
standard.token.exchange.enabled = "false"
One trap to avoid: there is a fourth attribute, oauth2.jwt.authorization.grant.audience, that is parsed as JSON. The UI does not expose it, but if you set it via the admin API to a bare URL (like the token endpoint), Keycloak returns invalid_grant "Could not deserialize json". Leave it unset — Keycloak then accepts any assertion aud matching the realm's token endpoint, which is what you want.
Link Users So Keycloak Will Mint Tokens for Them
This is the step the Keycloak docs hide behind two layers of admin-API navigation. For Keycloak to issue a token for a user based on an assertion from Kong, that user must have a federated-identity link to the kong-gateway provider.
Conceptually: “this user is also known to kong-gateway by the username <their-name>."
Pick a sub convention and stick to it. The JWT's sub claim, the userName you pass when creating the federated-identity link, and the value Kong sends from kong.client.consumer.username must all be the same string. If your API-key consumers live in Keycloak under a prefix (we used api.<name> to keep them visually separate from human users), then Kong has to mint assertions with sub: "api.<name>" and the federated-identity link has to be created with the same api.<name>. The article uses bare usernames for brevity — if you prefix yours, prefix consistently everywhere.
Through the admin API:
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"identityProvider\":\"kong-gateway\",\"userId\":\"$USERNAME\",\"userName\":\"$USERNAME\"}" \
"$KC/admin/realms/$REALM/users/$USER_INTERNAL_ID/federated-identity/kong-gateway"
A 204 No Content means the link was created. A 409 means the link already exists (idempotent — safe to ignore). Anything else means something is wrong with the IdP, the client, or the user.
Linking does not lock the user in. This was the thing I most expected to break and that turned out to be a non-issue: adding a federated-identity link to
kong-gatewayis purely additive on the user record. The user's existing authentication paths — username/password through the standard browser login, any other federated IdP they already had, service accounts, refresh tokens — keep working exactly as before. The link only tells Keycloak "this user can additionally be asserted bykong-gatewayvia JWT Authorization Grant." Other clients in the realm that authenticate the same user through entirely unrelated flows see no change. Re-running the backfill on already-linked users returns409and changes nothing. You can safely run the link script across the entire user base without worrying about logout storms or broken sessions.
In practice you will need two paths:
- A backfill script that iterates over every existing API-key consumer and creates the link.
- A forward-path hook that creates the link as part of user creation going forward. Otherwise the very next new consumer hits Kong, gets all the way to Keycloak, and is rejected with
"No federated identity for issuer".
If the gateway’s request flow ever returns that exact error in production after a new consumer is onboarded, this is the wiring you forgot.
Kong: The Datakit Plugin
Kong’s Datakit plugin is a small declarative pipeline DSL that runs inside Kong — perfect for “fetch a token, cache it, attach it to the upstream request.” It is conceptually similar to writing a custom Lua plugin, except you describe the pipeline as YAML nodes and Datakit handles the wiring, caching, and error propagation.
Here is the full Datakit configuration that turns an authenticated Kong consumer into an upstream Authorization: Bearer ... header. Skim the YAML on a first pass — the walkthrough that follows explains each node, and only three of them carry the interesting logic.
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: datakit-jwt-bearer
plugin: datakit
config:
resources:
cache:
strategy: memory
vault:
client_id: "{vault://env/KONG_OIDC_CLIENT_ID}"
client_secret: "{vault://env/KONG_OIDC_CLIENT_SECRET}"
jwt_signing_key: "{vault://env/KONG_JWT_SIGNING_PRIVATE_KEY}"
nodes:
- name: consumer
type: property
property: kong.client.consumer
- name: jwt_claims
type: jq
input: consumer
jq: |
if .username == null or .username == "" then
error("no authenticated consumer")
else
{
iss: "https://kong.example.com/datakit",
sub: .username,
aud: "https://auth.example.com/realms/myrealm",
jti: ((now * 1000000 | floor | tostring) + "-" + .username)
}
end
- name: jwt_assertion
type: jwt_sign
algorithm: RS256
expires_in: 300
inputs:
claims: jwt_claims
key: vault.jwt_signing_key
- name: token_endpoint_headers
type: static
values:
Content-Type: application/x-www-form-urlencoded
Accept: application/json
- name: token_body
type: jq
inputs:
jwt: jwt_assertion.token
cid: vault.client_id
csec: vault.client_secret
jq: |
"grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer"
+ "&assertion=" + (.jwt | @uri)
+ "&client_id=" + (.cid | @uri)
+ "&client_secret=" + (.csec | @uri)
+ "&scope=" + ("openid profile" | @uri)
- name: token_call
type: call
url: "https://auth.example.com/realms/myrealm/protocol/openid-connect/token"
method: POST
timeout: 5000
ssl_verify: true
inputs:
body: token_body
headers: token_endpoint_headers
- name: cache_key
type: jq
input: consumer
jq: '.username'
- name: user_token_ttl
type: jq
input: token_call.body
jq: '.expires_in - 30'
- name: user_token_cache
type: cache
inputs:
key: cache_key
data: token_call.body
ttl: user_token_ttl
- name: upstream_auth_header
type: jq
input: user_token_cache.data
jq: '{"Authorization": ("Bearer " + .access_token)}'
output: service_request.headers
Walking the pipeline
**consumer (property).** Datakit's property node has a fixed whitelist of top-level keys it can pull from Kong's PDK. kong.client.consumer is one of them, and it is populated by the key-auth plugin upstream of Datakit in the plugin priority order (key-auth priority 1250 runs first; Datakit runs in the access phase after it). The rest of the pipeline reads from this node.
**jwt_claims (jq).** Builds the JWT payload. Three things matter here:
- The
error("...")branch — if no consumer was authenticated, the pipeline halts before signing anything. This is a guard against the (impossible-in-theory) case where Datakit is wired onto a route that has no auth. issis the stable string that matches the Keycloak IdP's Issuer field exactly. They must be byte-equal — even a trailing slash mismatch will get you"No Identity Provider for provided issuer".jtihas to be unique per request, otherwise Keycloak rejects on"Token reuse detected". Datakit 3.14'sjwt_signdoes not inject one automatically — you have to add it yourself. Microsecond timestamp + username is unique and monotonic per consumer, which is enough.
**jwt_assertion (jwt_sign).** RS256 signature over the claims, with a 5-minute lifetime. The signing key comes from the vault block — never inlined.
***vault://reference scoping.* Datakit only resolves{vault://...}references inside theresources.vaultblock. Inline a vault reference in astatic.valuesmap or inside a jq string and Datakit will pass the literal text through unchanged — the request will go out with the string{vault://env/...}in it. Always pull secrets into namedresources.vaultkeys first, then reference those keys (vault.client_id,vault.jwt_signing_key) from the nodes that need them.
**token_body (jq).** Builds the URL-encoded form body for the token endpoint POST. Note @uri on every value — the JWT assertion in particular contains +, /, and = characters that absolutely must be percent-encoded.
**token_call (call).** The actual HTTP POST to Keycloak's token endpoint. Five-second timeout; SSL verification on. If your dataplane policy enforces TLS verification globally (Kong's tls_certificate_verify=on), you cannot turn this off — your control plane will reject the plugin sync if you try.
**cache_key, user_token_ttl, user_token_cache (jq + cache).** This is where the design becomes production-ready. Datakit's cache node uses a producer pattern: the nodes feeding the cache only run on a miss. So token_call (and its dependency chain back to jwt_sign) only runs when there is no valid cached token for this consumer. The TTL is the token's own expires_in minus 30 seconds of safety margin.
For a single-replica dataplane, cache.strategy: memory is fine. For multi-replica, switch to redis once you care about cross-pod cache sharing — otherwise each pod warms its own cache and you'll see up to N redundant token mints across the fleet.
**upstream_auth_header (jq with output).** The terminal node. It takes the cached token, formats it as Authorization: Bearer ..., and writes it to service_request.headers — Datakit's implicit sink for headers attached to the outgoing request to the upstream.
The upstream service now receives a request with both the original apikey header (still there from the client) and a fresh Authorization: Bearer <user-token>. Backends that ignore the bearer keep working as before. Backends that want to switch to user-scoped auth can start honoring it. Migrations don't have to be coordinated.
Wire the Plugin to a Route
A KongPlugin on its own does nothing — it's a definition, not an attachment. To make it run, bind it to a route with a KongPluginBinding. Two reasons to bind per-route rather than globally: you almost certainly do not want this plugin running on routes that aren't API-key-authenticated, and you want to enable it incrementally.
apiVersion: configuration.konghq.com/v1alpha1
kind: KongPluginBinding
metadata:
name: my-api-route-datakit
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: gateway-control-plane
pluginRef:
kind: KongPlugin
name: datakit-jwt-bearer
scope: OnlyTargets
targets:
routeRef:
group: configuration.konghq.com
kind: KongRoute
name: my-api-route
Apply one binding per route you want covered. Also: Datakit has to be in the dataplane’s allowed-plugins list. With Kong Gateway Operator that means appending datakit to dataPlane.env.plugins (or whatever your dataplane spec calls it). If the plugin is defined but not in the dataplane's plugin list, the control plane will accept the config but the dataplane silently won't load it — a confusing failure mode worth checking first.
What Happens on One Request
To pull everything together, here is what happens on a single request:
- Client sends
GET /api/v4/...with headerapikey: <some-key>. - Kong’s
key-authplugin resolves the key to a consumer record, populatingkong.client.consumer. - Datakit runs. The
consumernode sees the resolved consumer with.username = "alice". cache_keyevaluates to"alice". Datakit checksuser_token_cachefor that key.
- Cache hit: the cached token’s
datais forwarded straight toupstream_auth_header. The signing and HTTP-call nodes are skipped entirely. - Cache miss:
jwt_claimsbuilds the payload,jwt_assertionsigns it,token_bodyURL-encodes the form,token_callPOSTs to Keycloak. Keycloak verifies the signature against the registered public key, checksissagainst thekong-gatewayIdP, confirmsaliceis federated-linked to that IdP, and issues a user-scoped access token. The response goes into the cache with TTL ≈ 5 minutes.
upstream_auth_headerwritesAuthorization: Bearer <token>onto the upstream-bound request.- Kong forwards the request to the upstream service.
A second request from the same consumer within the next 4.5 minutes hits Keycloak zero times.
Things That Will Bite You
A short field guide to the failure modes, in roughly the order I encountered them.
**invalid_grant "JWT Authorization Grant is not supported for the requested client"** — the client's oauth2.jwt.authorization.grant.enabled and .idp attributes aren't both set. The UI toggles in Keycloak 26.6 set both at once; if you went through the admin API, double-check both keys.
**invalid_grant "No Identity Provider for provided issuer"** — the assertion's iss doesn't byte-match the IdP's Issuer field. Most often a trailing slash or a http / https mismatch.
**invalid_grant "Identity Provider is not allowed for the client"** — the IdP exists, but the client's oauth2.jwt.authorization.grant.idp attribute doesn't list the IdP alias.
**invalid_grant "Could not deserialize json: <url>"** — someone set oauth2.jwt.authorization.grant.audience to a plain URL via the admin API. Delete that attribute.
**invalid_request "Token jti claim is required"** — Datakit's jwt_sign does not auto-inject jti. Make sure your jwt_claims jq node sets one explicitly.
**invalid_grant "Token reuse detected"** — jti is being repeated within the assertion lifetime. The microsecond timestamp recipe in the example above prevents this.
**invalid_grant "No federated identity for issuer"** — the user exists in the realm but has no federated-identity link to kong-gateway. Run the backfill, and fix the user-creation hook.
**unsupported_grant_type** — the Keycloak realm doesn't expose urn:ietf:params:oauth:grant-type:jwt-bearer in .well-known/openid-configuration. Either the Keycloak version is too old or the JWT Authorization Grant provider was never created. Re-check Step 1.
The thing all these errors have in common: Keycloak returns 400 with a precise reason in the response body. Log the response body in your dataplane during rollout — you will need it.
One gotcha that does not show up as a Keycloak error — and therefore deserves its own paragraph — is Datakit silently losing its vault-resolved secrets after a config-only sync. The setup: you change the Datakit plugin YAML, commit, let Konnect push the new config down to the dataplane, and the running pods pick up the change without restarting. The symptom: outgoing token requests start failing — sometimes with 401 unauthorized_client because client_secret is now empty, sometimes with a malformed body because client_id is missing. The Kubernetes Secrets look correct, the YAML looks correct, the Konnect sync log looks correct. The pod simply isn't re-resolving {vault://...} references after a hot config update.
Until this is fixed upstream, the workaround is simple: whenever you change the Datakit plugin config, force a dataplane rollout (kubectl rollout restart deployment/<your-dataplane>). Treat the Konnect sync as necessary but not sufficient. If you only ever change config alongside an image bump or a values change that triggers a rollout anyway, you may never hit this — but the moment you push a Datakit-only edit, the pods need to come up fresh.
Smoke Test Without Kong
Before you trust the whole chain, prove the Keycloak side in isolation. Sign an assertion locally and curl Keycloak directly:
ASSERTION=$(python3 -c "
import jwt, time, uuid
with open('/path/to/private.pem') as f: pk = f.read()
print(jwt.encode({
'iss': 'https://kong.example.com/datakit',
'sub': 'alice',
'aud': 'https://auth.example.com/realms/myrealm',
'iat': int(time.time()),
'exp': int(time.time()) + 300,
'jti': str(uuid.uuid4())
}, pk, algorithm='RS256'))")
curl -sS -X POST \
"https://auth.example.com/realms/myrealm/protocol/openid-connect/token" \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
-d "assertion=$ASSERTION" \
-d "client_id=kong-introspection-client" \
-d "client_secret=$CLIENT_SECRET" \
| jq -r .access_token \
| cut -d. -f2 | base64 -d 2>/dev/null | jq
Successful output is a decoded access token whose preferred_username is alice, whose azp is kong-introspection-client, and whose iss is the realm URL. If this fails, Kong is innocent — don't waste time looking there. The failure is in the IdP config, the client attributes, or the federated-identity link.
Once that returns a token, deploy the Datakit plugin and re-test with a real API-key request through Kong. If the upstream’s access log shows the expected user-scoped bearer attached, you are done.
Things to Decide Before You Ship
A few decisions you cannot postpone:
Per-environment keypairs. Generate a fresh RS256 keypair for each environment (dev, test, prod) and never reuse one. The private key lives in the env’s secrets vault; the public key is pasted into the env’s Keycloak IdP. The iss claim can be identical across environments (it is just a string identifier), but the keys must not be.
Cache strategy. Memory is fine for single-replica dataplanes. Multi-replica → switch to Redis once warm-up time and redundant token mints become a noticeable cost.
Forward-path federated-identity creation. Build the link into your user-creation flow. Backfill alone is a one-shot — without the forward path, every new user goes through a “first request fails, on-call investigates” cycle.
Roll Forward, Not Back
The single best property of this design is one I only appreciated after deploying it: the rollout is genuinely additive, and that makes it almost impossible to break production with it.
Datakit only adds an Authorization: Bearer ... header on top of an unchanged key-auth flow. Every existing request keeps working — same API key in, same upstream out. Upstream services that ignore the new bearer keep working unchanged. Upstream services that want to switch to user-scoped auth opt in one at a time, on their own schedule, by reading the bearer instead of (or alongside) whatever they read before.
If something goes wrong, you don’t roll back — you just turn Datakit off on the affected route. One config flag. Seconds. The gateway is back to its pre-rollout behavior; no upstream is aware anything changed.
If you take only one design idea from this article, take this one: when you can make a security migration purely additive, do it. The blast radius of “deploys a new auth header” is much smaller than the blast radius of “changes how auth works,” and you get to enable it per-route and observe before expanding.
Wrapping Up
The pieces are small. The wiring is finicky. Once it’s standing, the system is much cleaner than what came before: no custom headers, no shared secrets per consumer, no per-route bespoke authentication code in upstream services. Every backend gets the same OAuth contract regardless of how the consumer authenticated at the edge.
If you read the previous article and thought “okay but does anyone actually run this in production?” — yes. Here’s the recipe. The dead-end through Token Exchange isn’t in this article because the previous one already covered it; if you arrived here without that context, go back and read it first. The reason JWT Authorization Grant is the right tool only makes sense once you’ve understood why the obvious-looking tool isn’t.
Found this helpful? Follow me for more deep dives into authentication, authorization, and API security.
메타데이터
- post_id
- 7cf97b04feb2
- slug
- from-api-key-to-oauth-token-implementing-jwt-authorization-grant-with-kong-and-keycloak-7cf97b04feb2
- url
- https://medium.com/@vgzxkgmrpn/from-api-key-to-oauth-token-implementing-jwt-authorization-grant-with-kong-and-keycloak-7cf97b04feb2
- canonical_url
- https://medium.com/@vgzxkgmrpn/from-api-key-to-oauth-token-implementing-jwt-authorization-grant-with-kong-and-keycloak-7cf97b04feb2
- author_url
- https://medium.com/@vgzxkgmrpn
- status
- ok
- fetched_at
- 2026-06-18 00:10:23