Skip to content
agentgateway has joined the Agentic AI FoundationLearn more

For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.

Page as Markdown

Keycloak

Protect MCP servers with Keycloak as the authorization server.

Keycloak is an open source identity and access management solution. Agentgateway includes a native keycloak MCP authentication provider so that you can use Keycloak as the authorization server for your MCP servers.

In this guide, you run Keycloak locally, configure a realm for MCP clients, protect a sample MCP server with the keycloak provider, and verify that agentgateway rejects unauthenticated requests and admits tokens that Keycloak issues.

Why the Keycloak provider is needed

MCP clients follow the MCP authorization specification, which relies on OAuth behaviors that Keycloak implements differently. When you set provider.keycloak, agentgateway bridges these gaps as follows:

  • Fetches keys from {issuer}/protocol/openid-connect/certs, the non-standard endpoint that Keycloak uses instead of {issuer}/.well-known/jwks.json.
  • Serves authorization server metadata from Keycloak’s OpenID Connect discovery document, which works across Keycloak versions. Keycloak 26.4.0 and later also serve RFC 8414 metadata at /.well-known/oauth-authorization-server/realms/<realm>.
  • Proxies Dynamic Client Registration through the gateway. Keycloak 26.5.0 added CORS support on its registration endpoint (keycloak#8863), but it is off by default: you must list the MCP client’s origin in the realm’s Allowed Registration Web Origins client registration policy, or supply web origins with an initial access token. Proxying through the gateway avoids that realm configuration and also works on earlier Keycloak versions.

For the underlying mcpAuthentication fields, see MCP authentication.

Before you begin

  1. Install the agentgateway binary.
  2. Install Docker to run Keycloak locally.
  3. Install Node.js so that npx can run the sample MCP server.

The steps use a local Keycloak instance so that you can complete the guide end to end. To use an existing Keycloak instance instead, skip to Step 2 and replace http://localhost:8080 with your Keycloak URL throughout.

Step 1: Run Keycloak

  1. Start Keycloak in development mode. The start-dev command uses an embedded database, which is suitable for testing but not for production.

    docker run -d --name keycloak -p 8080:8080 \
      -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
      -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
      quay.io/keycloak/keycloak:26.7 start-dev
  2. Wait for Keycloak to accept requests.

    for i in $(seq 1 60); do
      if [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/realms/master)" = "200" ]; then
        echo "Keycloak is ready"
        break
      fi
      sleep 2
    done

Step 2: Create the realm, client, and user

The following steps use kcadm.sh, the Keycloak admin CLI, which ships inside the container.

  1. Define a helper so that each command is shorter, then log in.

    kcadm() { docker exec keycloak /opt/keycloak/bin/kcadm.sh "$@"; }
    
    kcadm config credentials --server http://localhost:8080 --realm master \
      --user admin --password admin
  2. Create a realm named mcp. The realm name appears in the issuer URL, which takes the form http://<keycloak-host>/realms/<realm>.

    kcadm create realms -s realm=mcp -s enabled=true -s sslRequired=NONE

    Important

    sslRequired=NONE is required for this local setup. Keycloak’s default of external rejects requests that do not arrive over HTTPS or from a local address, and a container reached through a published port does not count as local. Without this setting, every OIDC endpoint returns 403 with {"error":"invalid_request","error_description":"HTTPS required"}. Do not use this setting in production.

  3. Create a public client for your MCP clients. MCP clients are public clients that use PKCE, because they cannot keep a client secret. The directAccessGrantsEnabled setting lets you request a token with a username and password in Step 6, which keeps the verification steps scriptable.

    kcadm create clients -r mcp \
      -s clientId=agentgateway \
      -s publicClient=true \
      -s standardFlowEnabled=true \
      -s directAccessGrantsEnabled=true \
      -s 'redirectUris=["http://localhost:*","http://127.0.0.1:*"]'
  4. Add an audience mapper to the client so that the tokens Keycloak mints carry agentgateway in the aud claim.

    CLIENT_UUID=$(kcadm get clients -r mcp -q clientId=agentgateway \
      --fields id --format csv --noquotes)
    
    kcadm create "clients/${CLIENT_UUID}/protocol-mappers/models" -r mcp \
      -s name=agentgateway-audience \
      -s protocol=openid-connect \
      -s protocolMapper=oidc-audience-mapper \
      -s 'config."included.client.audience"=agentgateway' \
      -s 'config."access.token.claim"=true'

    Important

    The audience mapper is required. By default, Keycloak sets aud to account and records the client only in the azp claim, so the audiences value that you configure in Step 4 never matches and every request fails validation.

  5. Create a realm role and a user to assign it to. You use this role in Step 7.

    kcadm create roles -r mcp -s name=mcp-admin
    
    kcadm create users -r mcp \
      -s username=mcpuser -s enabled=true -s emailVerified=true \
      -s email=mcpuser@example.com -s firstName=MCP -s lastName=User
    
    kcadm set-password -r mcp --username mcpuser --new-password mcppassword
    
    kcadm add-roles -r mcp --uusername mcpuser --rolename mcp-admin

    Note

    Set the email and name fields when you create the user. Keycloak’s default user profile requires them, and a user that is missing them cannot get a token: the token endpoint returns invalid_grant with Account is not fully set up.

Step 3: Allow Dynamic Client Registration

MCP clients register themselves with the authorization server instead of using a client ID that you configure by hand. Keycloak’s default Trusted Hosts policy rejects anonymous registration from every host, so allow the local addresses that your MCP clients use.

  1. Get the ID of the realm’s Trusted Hosts client registration policy.

    TRUSTED_HOSTS_ID=$(kcadm get components -r mcp -q name="Trusted Hosts" \
      --fields id --format csv --noquotes)
  2. Update the policy to trust the local addresses.

    kcadm update "components/${TRUSTED_HOSTS_ID}" -r mcp \
      -s 'config."trusted-hosts"=["localhost","127.0.0.1"]' \
      -s 'config."host-sending-registration-request-must-match"=["false"]' \
      -s 'config."client-uris-must-match"=["true"]'

    The following table describes each setting.

    SettingDescription
    trusted-hostsThe hosts that may register clients. client-uris-must-match also validates redirect URIs against this list, so it must cover the callback addresses that your MCP clients use.
    host-sending-registration-request-must-matchMust be false. Agentgateway proxies registration requests, so Keycloak sees the gateway’s address rather than the MCP client’s, and the check never matches.
    client-uris-must-matchKeep true so that Keycloak still validates the redirect URIs in each registration request against trusted-hosts.

    If you skip this step, registration through the gateway returns 403 with Policy 'Trusted Hosts' rejected request to client-registration service.

Step 4: Configure and start agentgateway

  1. Create a config.yaml that exposes a sample MCP server on port 3000 and protects it with the keycloak provider.

    # yaml-language-server: $schema=https://agentgateway.dev/schema/config
    gateways:
      default:
        port: 3000
    routes:
    - backends:
      - mcp:
          targets:
          - name: everything
            stdio:
              cmd: npx
              args: ["@modelcontextprotocol/server-everything"]
      policies:
        mcpAuthentication:
          mode: strict
          issuer: http://localhost:8080/realms/mcp
          audiences:
          - agentgateway
          provider:
            keycloak: {}
          resourceMetadata:
            resource: http://localhost:3000/mcp
            scopesSupported:
            - openid
            bearerMethodsSupported:
            - header
    Review the following table to understand this configuration.
    SettingDescription
    issuerThe realm URL, in the form http://<keycloak-host>/realms/<realm>. This value must match the iss claim in the token.
    audiencesThe audience that your Keycloak realm issues, which is the value that the audience mapper in Step 2 adds. Keycloak does not support RFC 8707 resource indicators (keycloak#10169), and agentgateway has no workaround for this, so you must set the audience that the realm already mints.
    provider.keycloakEnables the Keycloak-specific behavior described in Why the Keycloak provider is needed. Takes no fields.
    jwksOptional. Because provider.keycloak is set, agentgateway derives the JWKS URL from the issuer. To fetch keys from somewhere else, such as a local file or an internal mirror, set jwks explicitly to override the derived URL.
  2. Start agentgateway.

    agentgateway -f config.yaml

    Example output:

    info  state_manager  loaded config from File("config.yaml")
    info  app            serving UI at http://localhost:15000/ui
    info  proxy::gateway started bind  bind="bind/3000"

Step 5: Verify that unauthenticated requests are rejected

Agentgateway runs in the foreground, so run the following commands in another terminal.

  1. Send an MCP initialize request without a token.

    curl -i -X POST http://localhost:3000/mcp \
      -H 'content-type: application/json' \
      -H 'accept: application/json, text/event-stream' \
      -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}},"id":1}'

    Agentgateway returns 401 with a WWW-Authenticate header that points MCP clients at the protected resource metadata.

    HTTP/1.1 401 Unauthorized
    www-authenticate: Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/mcp"
    
    {"error":"unauthorized","error_description":"JWT token required"}
  2. Follow that pointer to see the metadata that the gateway serves.

    curl -s http://localhost:3000/.well-known/oauth-protected-resource/mcp

    Example output:

    {"resource":"http://localhost:3000/mcp","authorization_servers":["http://localhost:3000/mcp"],"mcp_protocol_version":"2025-06-18","resource_type":"mcp-server","bearer_methods_supported":["header"],"scopes_supported":["openid"]}
  3. Confirm that agentgateway advertises its own registration endpoint rather than Keycloak’s, which is how it proxies Dynamic Client Registration.

    curl -s http://localhost:3000/.well-known/oauth-authorization-server

    The registration_endpoint points back at the gateway, while the other endpoints point at Keycloak.

    ...
    "registration_endpoint": "http://localhost:3000/.well-known/oauth-authorization-server/client-registration",
    "authorization_endpoint": "http://localhost:8080/realms/mcp/protocol/openid-connect/auth",
  4. Register a client through that endpoint to confirm that the proxy and the policy from Step 3 work together.

    curl -s -X POST http://localhost:3000/.well-known/oauth-authorization-server/client-registration \
      -H 'content-type: application/json' \
      -d '{"client_name":"mcp-inspector","redirect_uris":["http://localhost:6274/oauth/callback"],"grant_types":["authorization_code"],"response_types":["code"],"token_endpoint_auth_method":"none"}'

    Keycloak returns the registered client, including a generated client_id.

    ...
    "client_id":"42b59ad1-..."

Step 6: Call the MCP server with a token

  1. Request a token for mcpuser.

    export TOKEN="$(curl -s -X POST \
      http://localhost:8080/realms/mcp/protocol/openid-connect/token \
      -d grant_type=password -d client_id=agentgateway \
      -d username=mcpuser -d password=mcppassword -d scope=openid \
      | jq -r .access_token)"
  2. Send the token as a bearer token.

    curl -i -X POST http://localhost:3000/mcp \
      -H "authorization: Bearer ${TOKEN}" \
      -H 'content-type: application/json' \
      -H 'accept: application/json, text/event-stream' \
      -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}},"id":1}'

    Agentgateway validates the token against Keycloak’s keys and returns the MCP server’s response.

    HTTP/1.1 200 OK
    content-type: text/event-stream
    mcp-session-id: f4d2e962-ae16-4aa4-9199-7db1ed05fba9
    
    event: message
    data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05", ... ,"serverInfo":{"name":"mcp-servers/everything","title":"Everything Reference Server","version":"2.0.0"}}}

    Tip

    The password grant is a convenience for this guide. Real MCP clients use the authorization code flow with PKCE, which the gateway advertises through the metadata that you inspected in Step 5.

Step 7: Restrict access by realm role

Because MCP authentication runs at the route level, you can use claims from the validated Keycloak token in an authorization policy.

  1. Add an authorization policy alongside mcpAuthentication in your config.yaml that requires the mcp-admin realm role.

      policies:
        mcpAuthentication:
          mode: strict
          issuer: http://localhost:8080/realms/mcp
          audiences:
          - agentgateway
          provider:
            keycloak: {}
          resourceMetadata:
            resource: http://localhost:3000/mcp
            scopesSupported:
            - openid
            bearerMethodsSupported:
            - header
        authorization:
          rules:
          # Check for the mcp-admin realm role in the token
          - '"mcp-admin" in jwt.realm_access.roles'
  2. Restart agentgateway to apply the policy. Because mcpuser has the mcp-admin role, the request from Step 6 still succeeds.

    agentgateway -f config.yaml
  3. To confirm that the rule is enforced, create a user without the role and repeat the request.

    kcadm create users -r mcp \
      -s username=noroleuser -s enabled=true -s emailVerified=true \
      -s email=noroleuser@example.com -s firstName=No -s lastName=Role
    
    kcadm set-password -r mcp --username noroleuser --new-password mcppassword
    
    export NO_ROLE_TOKEN="$(curl -s -X POST \
      http://localhost:8080/realms/mcp/protocol/openid-connect/token \
      -d grant_type=password -d client_id=agentgateway \
      -d username=noroleuser -d password=mcppassword -d scope=openid \
      | jq -r .access_token)"
    
    curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/mcp \
      -H "authorization: Bearer ${NO_ROLE_TOKEN}" \
      -H 'content-type: application/json' \
      -H 'accept: application/json, text/event-stream' \
      -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}},"id":1}'

    The token is valid, so authentication succeeds, but the authorization rule denies the request with 403.

    Created new user with id '3487ca67-...'
    403

Connect an MCP client

Point your MCP client at the gateway’s MCP endpoint, http://localhost:3000/mcp. The client discovers the authorization server through the gateway, registers through the gateway-proxied registration endpoint that you verified in Step 5, and redirects the user to Keycloak to log in and consent.

If your MCP client runs in a browser, also add its origin to the realm’s Allowed Registration Web Origins client registration policy. Keycloak 26.5.0 and later send CORS headers on the registration endpoint only for origins listed in that policy.

Run agentgateway and Keycloak with Docker Compose

To run both as containers instead of running the agentgateway binary on your host, use the following Compose file with the config.yaml from Step 4 next to it. Set issuer to http://keycloak:8080/realms/mcp so that agentgateway resolves Keycloak by its service name.

services:
  agentgateway:
    image: cr.agentgateway.dev/agentgateway:latest
    ports:
      - "3000:3000"
    volumes:
      - ./config.yaml:/config.yaml:ro
    command: ["-f", "/config.yaml"]
    depends_on:
      - keycloak

  keycloak:
    image: quay.io/keycloak/keycloak:26.7
    ports:
      - "8080:8080"
    environment:
      - KC_BOOTSTRAP_ADMIN_USERNAME=admin
      - KC_BOOTSTRAP_ADMIN_PASSWORD=admin
    command: start-dev

Because the issuer hostname differs inside and outside the Compose network, tokens that you request from http://localhost:8080 carry an iss claim that does not match. Request tokens from http://keycloak:8080 instead, such as from another container on the same network.

Clean up

Remove the Keycloak container and stop agentgateway.

docker rm -f keycloak

Learn more

Was this page helpful?
Agentgateway assistant

Ask me anything about agentgateway configuration, features, or usage.

Note: AI-generated content might contain errors; please verify and test all returned information.

Tip: one topic per conversation gives the best results. Use the + button in the chat header to start a new conversation.

Switching topics? Starting a new conversation improves accuracy.
↑↓ navigate select esc dismiss

What could be improved?

Your feedback helps us improve assistant answers and identify docs gaps we should fix.

Need more help? Join us on Discord: https://discord.gg/y9efgEmppm

Want to use your own agent? Add the Solo MCP server to query our docs directly. Get started here: https://search.solo.io/.