Authorize M2M Requests with Keycloak
Configure Apache APISIX to validate Keycloak access tokens and authorize machine-to-machine requests by audience and scope.
Applies to:Apache APISIX 3.18.0
Roles:Platform engineerSecurity engineerTime:25 minutesImpact:No restart, no traffic change
Goal
A route that accepts Keycloak service-account tokens only when their signature or introspection result, issuer, protected-API audience, and apisix.read scope are valid, then removes authentication headers before proxying upstream.
Before you begin
- You have an Apache APISIX installation, cURL, and jq. Get APISIX installs APISIX in Docker or Kubernetes.
- You have administrator access to a Keycloak server, or Docker for the isolated development server created in step 1.
Set the APISIX variables this guide uses:
export APISIX_ADMIN_URL="http://127.0.0.1:9180"
export GATEWAY_URL="http://127.0.0.1:9080"
read -rsp "Admin API key: " APISIX_ADMIN_KEY && export APISIX_ADMIN_KEY && echo-
The Admin API accepts your key. Check it with:
curl -s -o /dev/null -w '%{http_code}\n' "${APISIX_ADMIN_URL}/apisix/admin/routes" \ -H "X-API-KEY: ${APISIX_ADMIN_KEY}"Expected:
200. -
The data plane is reachable. Check it with:
curl -sI "${GATEWAY_URL}" | grep -i '^server'Expected: a
Server: APISIX/3.18.0header. -
The route ID
keycloak-m2mand path/anything/m2m/*are unused. Check both with:curl -s -o /dev/null -w '%{http_code}\n' \ "${APISIX_ADMIN_URL}/apisix/admin/routes/keycloak-m2m" \ -H "X-API-KEY: ${APISIX_ADMIN_KEY}" curl -s "${APISIX_ADMIN_URL}/apisix/admin/routes" \ -H "X-API-KEY: ${APISIX_ADMIN_KEY}" | \ jq -r '.list[] | select(.value.uri == "/anything/m2m/*") | .key'Expected:
404from the first command and no output from the second. A200or listed key means a collision; stop and choose a unique route ID and path rather than overwriting or shadowing an existing route.
Steps
Five steps prepare Keycloak, define the protected audience and scope, authorize a service account, capture the connection values, and create the APISIX route.
Step 1: Prepare a Keycloak realm
Reuse an existing test realm, or start an isolated development server with a temporary administrator account:
docker run -d --name apisix-keycloak \
-e 'KC_BOOTSTRAP_ADMIN_USERNAME=quickstart-admin' \
-e 'KC_BOOTSTRAP_ADMIN_PASSWORD=quickstart-admin-pass' \
-p 8080:8080 \
quay.io/keycloak/keycloak:26.7.3 start-devb7f3…Open http://localhost:8080/admin/, sign in as quickstart-admin, and create the realm quickstart-realm under Manage realms → Create realm.
What changed: a development-only Keycloak realm is available for the clients and scope created by this guide.
Step 2: Define the API scope and protected audience
In quickstart-realm, create the client scope and protected-API client:
- Select Client scopes → Create client scope, name it
apisix.read, keep Protocol set to OpenID Connect, enable Include in token scope, and save it. - Select Clients → Create client, keep Client type as OpenID Connect, set Client ID to
apisix-protected-api, and continue. - Enable Client authentication, leave all authentication flows disabled, and save the client.
Expected: Client scopes lists apisix.read, and Clients lists the confidential client apisix-protected-api with no interactive authentication flow enabled.
What changed: Keycloak now has the scope APISIX will require and a confidential client whose ID identifies the protected API audience.
Step 3: Authorize the service-account client
Create the calling client and add the required scope and audience:
- Select Clients → Create client, keep Client type as OpenID Connect, and set Client ID to
apisix-m2m-client. - Enable Client authentication and Service account roles, leave Standard flow and other interactive flows disabled, and save the client.
- On its Client scopes tab, add
apisix.readas an optional client scope. - Open the client's dedicated scope, select Add mapper → By configuration → Audience, name it
apisix-audience, chooseapisix-protected-apias the included client audience, enable Add to access token, and save it.
Expected: the service-account client lists apisix.read as optional and its dedicated scope contains an audience mapper for apisix-protected-api.
What changed: the M2M client can request apisix.read, and its access tokens identify the protected API in the aud claim.
Step 4: Set the Keycloak connection values
Set a Keycloak origin that is reachable from APISIX, then copy the two client secrets from each client's Credentials tab:
read -rp "Keycloak origin reachable from APISIX: " KEYCLOAK_URL && export KEYCLOAK_URL
export KEYCLOAK_DISCOVERY="${KEYCLOAK_URL}/realms/quickstart-realm/.well-known/openid-configuration"
export KEYCLOAK_API_CLIENT_ID="apisix-protected-api"
read -rsp "Protected API client secret: " KEYCLOAK_API_CLIENT_SECRET && export KEYCLOAK_API_CLIENT_SECRET && echo
export KEYCLOAK_M2M_CLIENT_ID="apisix-m2m-client"
read -rsp "M2M client secret: " KEYCLOAK_M2M_CLIENT_SECRET && export KEYCLOAK_M2M_CLIENT_SECRET && echo
export KEYCLOAK_M2M_SCOPE="apisix.read"Confirm that the discovery document publishes both the JWKS and introspection endpoints:
curl -sS "${KEYCLOAK_DISCOVERY}" | jq -er '{jwks_uri, introspection_endpoint}'{
"jwks_uri": "http://keycloak.example/realms/quickstart-realm/protocol/openid-connect/certs",
"introspection_endpoint": "http://keycloak.example/realms/quickstart-realm/protocol/openid-connect/token/introspect"
}What changed: the local shell holds the provider endpoints, client identities, secrets, and required scope that the remaining commands consume.
Step 5: Create the protected route
Choose local JWKS validation for no per-request provider dependency, or introspection when immediate token-state checks are required.
curl -s "${APISIX_ADMIN_URL}/apisix/admin/routes/keycloak-m2m" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<EOF
{
"uri": "/anything/m2m/*",
"plugins": {
"openid-connect": {
"client_id": "${KEYCLOAK_API_CLIENT_ID}",
"discovery": "${KEYCLOAK_DISCOVERY}",
"bearer_only": true,
"use_jwks": true,
"claim_validator": {
"audience": {
"required": true,
"match_with_client_id": true
}
},
"required_scopes": ["${KEYCLOAK_M2M_SCOPE}"],
"set_access_token_header": false,
"set_id_token_header": false,
"set_userinfo_header": false
},
"proxy-rewrite": {
"headers": {"remove": ["Authorization"]}
}
},
"upstream": {
"type": "roundrobin",
"nodes": {"httpbin.org:80": 1}
}
}
EOFKeycloak permits only confidential clients to call its introspection endpoint. Current Keycloak releases also report a token as inactive when the authenticated introspection client is not in that token's audience; the audience mapper from step 3 satisfies that requirement.
curl -s "${APISIX_ADMIN_URL}/apisix/admin/routes/keycloak-m2m" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<EOF
{
"uri": "/anything/m2m/*",
"plugins": {
"openid-connect": {
"client_id": "${KEYCLOAK_API_CLIENT_ID}",
"client_secret": "${KEYCLOAK_API_CLIENT_SECRET}",
"discovery": "${KEYCLOAK_DISCOVERY}",
"bearer_only": true,
"claim_validator": {
"audience": {
"required": true,
"match_with_client_id": true
}
},
"required_scopes": ["${KEYCLOAK_M2M_SCOPE}"],
"set_access_token_header": false,
"set_id_token_header": false,
"set_userinfo_header": false
},
"proxy-rewrite": {
"headers": {"remove": ["Authorization"]}
}
},
"upstream": {
"type": "roundrobin",
"nodes": {"httpbin.org:80": 1}
}
}
EOF{ "value": { "id": "keycloak-m2m", "uri": "/anything/m2m/*", "plugins": { "openid-connect": { "bearer_only": true, … } }, … } }What changed: /anything/m2m/* now requires a Keycloak token for apisix-protected-api with apisix.read, using the selected validation mode, and bearer credentials are not forwarded upstream.
Validate
Request a service-account token with the required optional scope:
KEYCLOAK_ACCESS_TOKEN="$(
curl -sS "${KEYCLOAK_URL}/realms/quickstart-realm/protocol/openid-connect/token" \
--user "${KEYCLOAK_M2M_CLIENT_ID}:${KEYCLOAK_M2M_CLIENT_SECRET}" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "scope=${KEYCLOAK_M2M_SCOPE}" | \
jq -er '.access_token'
)" && export KEYCLOAK_ACCESS_TOKENSend the token to APISIX and verify that httpbin did not receive authentication headers:
curl -sS "${GATEWAY_URL}/anything/m2m/get" \
-H "Authorization: Bearer ${KEYCLOAK_ACCESS_TOKEN}" | \
jq '{authorization_forwarded: (.headers.Authorization != null), access_token_forwarded: (.headers["X-Access-Token"] != null), id_token_forwarded: (.headers["X-Id-Token"] != null), userinfo_forwarded: (.headers["X-Userinfo"] != null)}'{
"authorization_forwarded": false,
"access_token_forwarded": false,
"id_token_forwarded": false,
"userinfo_forwarded": false
}Request a token without the optional scope and verify that APISIX rejects it:
TOKEN_WITHOUT_SCOPE="$(
curl -sS "${KEYCLOAK_URL}/realms/quickstart-realm/protocol/openid-connect/token" \
--user "${KEYCLOAK_M2M_CLIENT_ID}:${KEYCLOAK_M2M_CLIENT_SECRET}" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" | \
jq -er '.access_token'
)"
curl -s -o /dev/null -w '%{http_code}\n' "${GATEWAY_URL}/anything/m2m/get" \
-H "Authorization: Bearer ${TOKEN_WITHOUT_SCOPE}"403A missing or malformed token returns 401:
curl -s -o /dev/null -w '%{http_code}\n' "${GATEWAY_URL}/anything/m2m/get"
curl -s -o /dev/null -w '%{http_code}\n' "${GATEWAY_URL}/anything/m2m/get" \
-H "Authorization: Bearer invalid-access-token"401
401Roll back / Clean up
Delete the APISIX route before removing the identity-provider resources it depends on:
curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/routes/keycloak-m2m" \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}"Expected: the Admin API returns a successful deletion response. The protected path stops matching this route immediately.
If you used an existing Keycloak realm, delete apisix-m2m-client, apisix-protected-api, and apisix.read in that order only when no other integration uses them. If step 1 created the isolated development container and its realm is disposable, remove that container instead:
docker rm -f apisix-keycloakapisix-keycloakTroubleshoot
| Symptom | Cause | Fix |
|---|---|---|
| APISIX cannot load discovery metadata or keys. | The Keycloak origin is reachable from the host but not from the APISIX container or network. | Set KEYCLOAK_URL to an origin resolvable from APISIX, reload the route, and fetch ${KEYCLOAK_DISCOVERY} from the same network. |
APISIX returns 401 for a newly issued token. | The token's issuer, audience, signature, or introspection state does not match the route; introspection also requires the calling client to be in aud. | Inspect the token claims, confirm the audience mapper adds apisix-protected-api, and request a new token. |
APISIX returns 403 for a valid token. | The token lacks apisix.read because the optional client scope was not requested or linked. | Confirm the M2M client has the optional scope and request it with scope=${KEYCLOAK_M2M_SCOPE}. |
Related
- Set Up SSO with Keycloak — use the same provider for browser-based user authentication.
openid-connectplugin overview (opens in Plugin Hub docs) — compare bearer-token validation with the plugin's other OIDC flows.openid-connectplugin reference (opens in Plugin Hub docs) — JWKS, introspection, audience, scope, and upstream-header options.- Keycloak OIDC endpoints — Keycloak's external discovery, certificate, token, and introspection endpoint reference.