For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.
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
- Install the agentgateway binary.
- Install Docker to run Keycloak locally.
- Install Node.js so that
npxcan 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
Start Keycloak in development mode. The
start-devcommand 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-devWait 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.
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 adminCreate a realm named
mcp. The realm name appears in the issuer URL, which takes the formhttp://<keycloak-host>/realms/<realm>.kcadm create realms -s realm=mcp -s enabled=true -s sslRequired=NONEImportant
sslRequired=NONEis required for this local setup. Keycloak’s default ofexternalrejects 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 returns403with{"error":"invalid_request","error_description":"HTTPS required"}. Do not use this setting in production.Create a public client for your MCP clients. MCP clients are public clients that use PKCE, because they cannot keep a client secret. The
directAccessGrantsEnabledsetting 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:*"]'Add an audience mapper to the client so that the tokens Keycloak mints carry
agentgatewayin theaudclaim.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
audtoaccountand records the client only in theazpclaim, so theaudiencesvalue that you configure in Step 4 never matches and every request fails validation.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-adminNote
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_grantwithAccount 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.
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)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.
Setting Description trusted-hostsThe hosts that may register clients. client-uris-must-matchalso 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 trueso that Keycloak still validates the redirect URIs in each registration request againsttrusted-hosts.If you skip this step, registration through the gateway returns
403withPolicy 'Trusted Hosts' rejected request to client-registration service.
Step 4: Configure and start agentgateway
Create a
config.yamlthat exposes a sample MCP server on port 3000 and protects it with thekeycloakprovider.Review the following table to understand this configuration.# 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: - headerSetting Description issuerThe realm URL, in the form http://<keycloak-host>/realms/<realm>. This value must match theissclaim 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.keycloakis set, agentgateway derives the JWKS URL from the issuer. To fetch keys from somewhere else, such as a local file or an internal mirror, setjwksexplicitly to override the derived URL.Start agentgateway.
agentgateway -f config.yamlExample 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.
Send an MCP
initializerequest 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
401with aWWW-Authenticateheader 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"}Follow that pointer to see the metadata that the gateway serves.
curl -s http://localhost:3000/.well-known/oauth-protected-resource/mcpExample 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"]}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-serverThe
registration_endpointpoints 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",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
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)"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.
Add an
authorizationpolicy alongsidemcpAuthenticationin yourconfig.yamlthat requires themcp-adminrealm 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'Restart agentgateway to apply the policy. Because
mcpuserhas themcp-adminrole, the request from Step 6 still succeeds.agentgateway -f config.yamlTo 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-devBecause 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