Docs

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.0 header.

  • The route ID keycloak-m2m and 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: 404 from the first command and no output from the second. A 200 or 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-dev
b7f3…

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:

  1. Select Client scopes → Create client scope, name it apisix.read, keep Protocol set to OpenID Connect, enable Include in token scope, and save it.
  2. Select Clients → Create client, keep Client type as OpenID Connect, set Client ID to apisix-protected-api, and continue.
  3. 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:

  1. Select Clients → Create client, keep Client type as OpenID Connect, and set Client ID to apisix-m2m-client.
  2. Enable Client authentication and Service account roles, leave Standard flow and other interactive flows disabled, and save the client.
  3. On its Client scopes tab, add apisix.read as an optional client scope.
  4. Open the client's dedicated scope, select Add mapper → By configuration → Audience, name it apisix-audience, choose apisix-protected-api as 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}
  }
}
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_TOKEN

Send 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}"
403

A 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
401

Roll 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-keycloak
apisix-keycloak

Troubleshoot

SymptomCauseFix
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}.