Authorize M2M Requests with Keycloak
Keycloak can issue access tokens to service accounts associated with confidential OIDC clients. Background services, scheduled jobs, command-line tools, and automation can use these tokens to call APIs without an end user signing in.
The OAuth 2.0 Client Credentials grant is designed for this type of machine-to-machine (M2M) communication. Apache APISIX can protect the target API by validating each token's signature, issuer, audience, and granted scope before proxying the request.
The guide uses one Keycloak client to represent the API protected by APISIX and another service-account client to represent the calling service. The service account receives a client scope and requests an access token whose audience identifies the protected API. APISIX validates both restrictions and removes authentication data before forwarding the request to the sample upstream. The primary configuration validates tokens locally with Keycloak's JSON Web Key Set (JWKS); an alternative configuration uses Keycloak's token introspection endpoint.
Prerequisite(s)
- Install Docker.
- Install cURL and jq.
- Follow the Getting Started tutorial to start APISIX with Docker.
- If you plan to use ADC, install and configure ADC before continuing.
Configure Keycloak
Start a local Keycloak server, then create a realm, a protected-API client, a client scope, and a service-account client for M2M authorization.
Start Keycloak
If Keycloak and quickstart-realm are already available from Set Up SSO with Keycloak, reuse them and continue with Create an API Scope.
Otherwise, start Keycloak in development mode 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-dev
Development mode and the example credentials are intended only for local testing. For a production deployment, use HTTPS, a production database, and a permanent administrator account.
Open http://localhost:8080/admin/ and sign in with the administrator username quickstart-admin and password quickstart-admin-pass.
Create a Realm
Create an isolated realm for this guide:
- Select Manage realms → Create realm.
- Enter
quickstart-realmas the realm name. - Select Create.

Create an API Scope
Create the scope that APISIX will require from M2M access tokens:
- Select Client scopes → Create client scope.
- Enter
apisix.readas the name and keep Protocol set to OpenID Connect. - Turn on Include in token scope and select Save.
Create a Protected API Client
Create a confidential client that represents the API protected by APISIX:
- Select Clients → Create client.
- Keep Client type set to OpenID Connect, enter
apisix-protected-apias the client ID, and select Next. - Turn on Client authentication. Leave all authentication flows off and select Save.
APISIX uses this client ID as the expected token audience. The client secret is needed only for the optional token-introspection configuration.
Create an M2M Client
Create a confidential client with a Keycloak service account:
- Select Clients → Create client.
- Keep Client type set to OpenID Connect, enter
apisix-m2m-clientas the client ID, and select Next. - Turn on Client authentication and Service account roles. Leave Standard flow and the other authentication flows off, then select Save.
- Open the Client scopes tab and select Add client scope.
- Select
apisix.read, select Add, and add it as an optional client scope.
The service account allows this client to obtain tokens through the Client Credentials grant. A redirect URI is not required because the flow does not redirect a browser or authenticate an end user.
Add the Token Audience
Add the protected API client as the audience of access tokens issued to the M2M client:
- Open Clients → apisix-m2m-client → Client scopes.
- Select
apisix-m2m-client-dedicatedand then select Add mapper → By configuration → Audience. - Enter
apisix-audienceas the name and selectapisix-protected-apias the included client audience. - Keep Add to access token on and select Save.

The access token will contain apisix-protected-api in its aud claim. This separates the calling service from the protected API and allows APISIX to reject tokens issued for a different audience.
Save the OAuth Configuration
Select Realm settings. On the General tab, find Endpoints and open OpenID Endpoint Configuration. The APISIX container must be able to reach Keycloak at the discovery document's hostname.
Save a reachable host address and the discovery endpoint as environment variables, replacing the example address:
export KEYCLOAK_HOST=192.168.1.100
export KEYCLOAK_DISCOVERY="http://${KEYCLOAK_HOST}:8080/realms/quickstart-realm/.well-known/openid-configuration"
Select Clients → apisix-m2m-client → Credentials and copy the calling client's secret. Then open Clients → apisix-protected-api → Credentials and copy the protected API client's secret.
Save the client IDs, secrets, and required scope as environment variables:
export KEYCLOAK_API_CLIENT_ID=apisix-protected-api
export KEYCLOAK_API_CLIENT_SECRET=replace-with-your-protected-api-client-secret
export KEYCLOAK_M2M_CLIENT_ID=apisix-m2m-client
export KEYCLOAK_M2M_CLIENT_SECRET=replace-with-your-client-secret
export KEYCLOAK_M2M_SCOPE=apisix.read
Keep the client secret confidential. Store production credentials in a secret manager and rotate them according to the organization's credential-rotation policy.
Configure Local JWT Validation
Configure a route that validates Keycloak bearer tokens locally before forwarding requests to httpbin.org, a public HTTP request and response service. The /anything/m2m/* endpoint returns request details for verification.
Choose either the Admin API or ADC to configure the route.
- Admin API
- ADC
Create a route that validates bearer tokens with Keycloak's JWKS:
curl "http://127.0.0.1:9180/apisix/admin/routes/keycloak-m2m" -X PUT \
--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
}
}
}
EOF
❶ bearer_only and use_jwks: Require a bearer access token and validate its JWT signature locally with the public keys published by Keycloak.
❷ claim_validator.audience: Requires the token's aud claim to contain the protected API client ID configured as client_id.
❸ required_scopes: Requires the access token to include the API scope assigned to the service-account client.
❹ set_access_token_header, set_id_token_header, and set_userinfo_header: Set to false to prevent APISIX from adding the access token, ID token, and token claims to upstream request headers.
❺ proxy-rewrite.headers.remove: Removes the original bearer token before APISIX proxies the request. Review these header settings if the upstream application must receive the access token or its claims.
Create an adc.yaml file with the same route configuration:
services:
- name: httpbin
routes:
- name: keycloak-m2m
uris:
- /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:
- host: httpbin.org
port: 80
weight: 1
❶ bearer_only and use_jwks: Require a bearer access token and validate its JWT signature locally with the public keys published by Keycloak.
❷ claim_validator.audience: Requires the token's aud claim to contain the protected API client ID configured as client_id.
❸ required_scopes: Requires the access token to include the API scope assigned to the service-account client.
❹ set_access_token_header, set_id_token_header, and set_userinfo_header: Set to false to prevent APISIX from adding the access token, ID token, and token claims to upstream request headers.
❺ proxy-rewrite.headers.remove: Removes the original bearer token before APISIX proxies the request. Review these header settings if the upstream application must receive the access token or its claims.
Synchronize the configuration to APISIX:
adc sync -f adc.yaml
Verify M2M Authorization
Request an access token from Keycloak. The --user option sends the client ID and secret using HTTP Basic authentication:
export KEYCLOAK_ACCESS_TOKEN="$(
curl -sS "http://${KEYCLOAK_HOST}:8080/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'
)"
Send the access token to the protected route:
curl -i "http://127.0.0.1:9080/anything/m2m/get" \
-H "Authorization: Bearer ${KEYCLOAK_ACCESS_TOKEN}"
An HTTP/1.1 200 OK response verifies that APISIX accepted a Keycloak access token for the configured audience and scope. The response body should contain fields similar to these:
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Host": "127.0.0.1",
"User-Agent": "curl/8.7.1",
"X-Amzn-Trace-Id": "Root=1-...",
"X-Forwarded-Host": "127.0.0.1:9080"
},
"json": null,
"method": "GET",
"origin": "192.168.155.1, xxx.xxx.xxx.xxx",
"url": "http://127.0.0.1:9080/anything/m2m/get"
}
Header values and the reported origin address vary by environment. The upstream request headers should not include the bearer token, access token, ID token, or token claims.
Request a token without the required scope and send it to the route:
TOKEN_WITHOUT_SCOPE="$(
curl -sS "http://${KEYCLOAK_HOST}:8080/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 -i "http://127.0.0.1:9080/anything/m2m/get" \
-H "Authorization: Bearer ${TOKEN_WITHOUT_SCOPE}"
APISIX returns HTTP/1.1 403 Forbidden because the token does not include apisix.read.
Send requests without a token and with a malformed token:
curl -i "http://127.0.0.1:9080/anything/m2m/get"
curl -i "http://127.0.0.1:9080/anything/m2m/get" \
-H "Authorization: Bearer invalid-access-token"
APISIX returns HTTP/1.1 401 Unauthorized for both requests.
Validate Tokens with Introspection
Local JWT validation avoids a provider request for each token check. If immediate token-state checks are required, APISIX can instead send the bearer token to Keycloak's introspection endpoint. The audience mapper configured earlier allows the protected API client to introspect the M2M client's tokens.
Choose either the Admin API or ADC to replace the route configuration.
- Admin API
- ADC
Create the route without use_jwks and provide the client secret for introspection authentication:
curl "http://127.0.0.1:9180/apisix/admin/routes/keycloak-m2m" -X PUT \
--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
❶ client_secret: Authenticates APISIX as the protected API client to Keycloak's introspection endpoint. Without use_jwks, APISIX obtains that endpoint from the discovery document and validates bearer tokens remotely.
Update adc.yaml with the introspection configuration:
services:
- name: httpbin
routes:
- name: keycloak-m2m
uris:
- /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:
- host: httpbin.org
port: 80
weight: 1
❶ client_secret: Authenticates APISIX as the protected API client to Keycloak's introspection endpoint. Without use_jwks, APISIX obtains that endpoint from the discovery document and validates bearer tokens remotely.
Synchronize the updated configuration to APISIX:
adc sync -f adc.yaml
Request a new access token with the required scope and repeat the protected-route request. An HTTP/1.1 200 OK response verifies that Keycloak reported the token as active and APISIX accepted its audience and scope.
Next Steps
You have now configured APISIX to authorize M2M requests with Keycloak. To authenticate browser users with the same identity provider, see Set Up SSO with Keycloak. See the openid-connect plugin reference for more configuration options.