<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>OAuth on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/oauth/</link><description>Recent content in OAuth on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Sat, 12 Sep 2026 09:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/oauth/index.xml" rel="self" type="application/rss+xml"/><item><title>Completing Keycloak for MCP: the protected resource no SDK hands you ready-made, and the enterprise extension that skips the whole flow</title><link>https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/</link><pubDate>Sat, 12 Sep 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/</guid><description>&lt;blockquote>
&lt;p>A follow-up to &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>, which flagged the gap: resource indicators are not supported and protected resource metadata belongs to the MCP server. This post tries to close it. Verified against revision 2026-07-28 of the specification, the official Python and TypeScript SDKs, Keycloak 26.7.3 and LiteLLM 1.102.0.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The specification splits three obligations and the authorisation server covers only one.&lt;/strong> The MCP server must publish its protected resource metadata. The client must send the resource indicator in both requests, and must do so even if the authorisation server does not support it. The MCP server must validate that the token was issued for it. Keycloak does not understand the indicator and considers the metadata someone else&amp;rsquo;s, so all three end up in the resource layer.&lt;/p>
&lt;p>&lt;strong>The Python SDK mounts the metadata on its own, but degraded.&lt;/strong> As soon as the resource server URL is configured, the route appears. What that automatic route publishes carries a single authorisation server, reuses as its scope catalogue the ones the middleware requires, and leaves the name and the documentation null. To publish a complete document you have to mount the route by hand.&lt;/p>
&lt;p>&lt;strong>Audience validation comes switched off in Python and does not exist in TypeScript.&lt;/strong> In Python, if the resource URL is configured and the corresponding flag is not enabled, the SDK emits a deprecation warning and behaves as if it were disabled; the docstring itself promises that version 3 will set it to true. In TypeScript the bearer verification does three things, and none of them is looking at the audience. Neither of the two ships a signature verifier with public keys.&lt;/p>
&lt;p>&lt;strong>Token passthrough is forbidden in writing, and the substitute has a name.&lt;/strong> The specification says the MCP server must not accept or relay tokens that were not issued for it, and that if it calls upstream APIs the token must be a different one. The mechanism is token exchange, which Keycloak supports in its standard version with a limitation you need to know: the audience is a client identifier, not a resource URL.&lt;/p>
&lt;p>&lt;strong>There is an official extension that changes the whole picture, and it is stable.&lt;/strong> Enterprise-managed authorisation replaces the redirect to the MCP authorisation server with an exchange at the corporate identity provider, which evaluates the policy and issues a grant based on an identity assertion. It has been stable since June 2026 and there are servers in production.&lt;/p>
&lt;p>&lt;strong>Keycloak implements it halfway and in experimental.&lt;/strong> It only knows how to act as receiver, not as issuer, behind a feature flag, and against draft 01 when the working group is on 04. The official documentation literally says not to use it in production.&lt;/p>
&lt;p>&lt;strong>LiteLLM already implements it on the client side and does not document it.&lt;/strong> In 1.102.0 there is an authentication mode towards MCP servers that runs the two stages of that grant. There is not a single documentation file that mentions it.&lt;/p>
&lt;h2 id="you-are-here-the-resource-side">You are here: the resource side&lt;/h2>
&lt;p>The previous post walked through the identity provider. This one walks through what sits on the other side of the token, which is where the MCP specification puts almost all its normative obligations.&lt;/p>
&lt;p>The natural reading order is &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up&lt;/a> for the basic setup, &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> for the identity piece, and this one for the missing layer. The MCP gateway seen from the inside is &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in its own post&lt;/a>.&lt;/p>
&lt;h2 id="the-analogy-the-visa-and-the-border-checkpoint">The analogy: the visa and the border checkpoint&lt;/h2>
&lt;p>A consulate issues visas. A border post checks them. They are two different offices, and the classic mistake consists of assuming that because the visa is genuine it is good for entering through any door.&lt;/p>
&lt;p>The visa has written on it which country it is good for. That is the token&amp;rsquo;s audience, and here the problem appears: the consulate we work with does not know how to write the destination the traveller asks for, because it does not understand that field on the form. It knows how to write a destination if asked for it under a different name, by way of a pre-agreed stamp, which is the workaround of scopes and the audience mapper.&lt;/p>
&lt;p>And then there is the other half, the one almost nobody builds. The border post has to exist, it has to announce where it is and from which consulates it accepts visas, and it has to read the destination written on the visa before letting anyone through. If the border post limits itself to checking that the stamp is genuine and does not look at the destination, any valid visa from the same consulate is good for entering. That is exactly what the two official SDKs do by default.&lt;/p>
&lt;h2 id="part-1-the-split-of-obligations">Part 1. The split of obligations&lt;/h2>
&lt;p>From revision 2026-07-28, with the normative words as they are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Obligation&lt;/th>
&lt;th>Whose&lt;/th>
&lt;th>Status with Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Publish protected resource metadata (RFC 9728)&lt;/td>
&lt;td>MCP server, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Out of its scope, by design&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Send the resource indicator (RFC 8707) in authorisation and in token&lt;/td>
&lt;td>Client, &lt;strong>MUST&lt;/strong>, even if the authorisation server does not support it&lt;/td>
&lt;td>It does not understand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validate that the token was issued for oneself&lt;/td>
&lt;td>MCP server, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>The resource&amp;rsquo;s responsibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not accept or relay other tokens&lt;/td>
&lt;td>MCP server, &lt;strong>MUST NOT&lt;/strong>&lt;/td>
&lt;td>The resource&amp;rsquo;s responsibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validate the issuer of the authorisation response (RFC 9207)&lt;/td>
&lt;td>Client, &lt;strong>MUST&lt;/strong>; authorisation server &lt;strong>SHOULD&lt;/strong> emit it&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Consent for each dynamically registered client&lt;/td>
&lt;td>MCP proxy with a static identifier, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Configurable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The most confusing part of that split is the second line. The specification obliges the client to send the parameter &lt;strong>regardless of whether the authorisation server supports it&lt;/strong>. With Keycloak, that parameter is lost: the official documentation says it cannot recognise it, and the standard behaviour towards an unknown parameter is to ignore it. The client complies, the token comes out, and what does not come out is the correct audience. The failure appears later and somewhere else, which is the worst kind of failure.&lt;/p>
&lt;p>It is worth knowing where that work stands. The matter is issue 14355 of the project, open, with a milestone in 26.8.0. There was a complete implementation in pull request 35711, with a mapper of its own and an extension point for resolving resources, which &lt;strong>went back to draft in October 2025&lt;/strong> because of a total lack of tests and because it was decided to redo it in phases, starting with a single resource per client. In March 2026 a new issue was opened for experimental support, also with a milestone in 26.8.0 and with no associated pull request. In 26.7.x there is no feature flag that enables it.&lt;/p>
&lt;h3 id="what-is-new-in-the-revision-and-affects-the-setup">What is new in the revision and affects the setup&lt;/h3>
&lt;p>Four things changed with respect to the previous revision and are worth collecting:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RFC 9207.&lt;/strong> A new section on validating the authorisation response. The client must record the issuer from the validated metadata document, and the comparison is literal: you are not allowed to normalise case, elide the default port, add or remove the trailing slash or re-encode characters before comparing.&lt;/li>
&lt;li>&lt;strong>Offline tokens leave the catalogue.&lt;/strong> A new section on refresh tokens: MCP servers &lt;strong>should not&lt;/strong> include the corresponding scope either in the challenge header or in the supported scopes of their metadata.&lt;/li>
&lt;li>&lt;strong>Dynamic registration becomes deprecated.&lt;/strong> It goes from optional to optional and deprecated, retained for compatibility with authorisation servers that do not support client identifier metadata documents. And a new requirement is added to registration: the application type is mandatory, and a client must not reuse credentials from another authorisation server, it has to register again.&lt;/li>
&lt;li>&lt;strong>Step-up elevation is rewritten.&lt;/strong> The menu of three server strategies disappears and is replaced by two rules: the challenge&amp;rsquo;s scope attribute describes what is needed for the requested resource, with no obligation to include what has already been granted, and scope accumulation becomes the client&amp;rsquo;s responsibility. With a new requirement for the server: it &lt;strong>must&lt;/strong> take scope hierarchies into account, where a broad one implies the narrow ones.&lt;/li>
&lt;/ul>
&lt;h2 id="part-2-what-the-sdks-give-and-what-they-do-not">Part 2. What the SDKs give, and what they do not&lt;/h2>
&lt;p>Here is the uncomfortable part, and it is the main reason for writing this post. Verified by reading the code of the Python SDK and of the TypeScript 2.0.0-alpha one.&lt;/p>
&lt;h3 id="protected-resource-metadata">Protected resource metadata&lt;/h3>
&lt;p>&lt;strong>In Python the complete model exists and it mounts on its own.&lt;/strong> The metadata class carries the RFC fields: resource, authorisation servers with a minimum of one, key set URL, supported scopes, bearer methods with a default value of header, name, documentation, policy, terms, and the client certificate and DPoP fields. The handler serves the document with a one-hour cache directive, and the URL is built by inserting the well-known path in front of the resource path, as the RFC requires.&lt;/p>
&lt;p>As soon as the resource server URL is configured, the route appears with no further work. &lt;strong>But what that automatic route publishes is degraded in three points&lt;/strong>: it passes a single authorisation server, it takes as its scope catalogue the ones the middleware requires (which are not the same as the ones the resource supports), and it passes neither the name nor the documentation, which come out null. To publish a complete document you have to call the route creation function by hand.&lt;/p>
&lt;p>&lt;strong>In TypeScript it exists and does not mount on its own.&lt;/strong> The function that builds the document emits &lt;strong>only five fields&lt;/strong>: resource, authorisation servers, supported scopes, name and documentation. &lt;strong>It does not emit the supported bearer methods&lt;/strong>, nor the key set URL, nor anything about DPoP. And serving it is explicit: either you call the response function from your own handler, or you mount the metadata router. There is no point in the SDK that does it on its own.&lt;/p>
&lt;h3 id="audience-validation">Audience validation&lt;/h3>
&lt;p>This is the one to fix on day one.&lt;/p>
&lt;p>&lt;strong>In Python it comes disabled.&lt;/strong> The token verifier is a single-method protocol, that is to say a hole the implementer fills. The access token&amp;rsquo;s resource field is the indicator you set yourself. And the comparison only happens if you ask for it: there is a function that normalises as a URL and ignores the trailing slash, but the resource server URL reaches it empty unless the validation flag is enabled.&lt;/p>
&lt;p>The detail to read twice is in the configuration: &lt;strong>if the resource URL is set and the flag is not, the SDK raises a deprecation warning and behaves as if the flag were false&lt;/strong>. The docstring says version 3 will set the default to true. Until then, a Python server configured with authorisation accepts tokens issued for another resource, unless the implementer enables the flag or validates the audience inside their own verifier.&lt;/p>
&lt;p>&lt;strong>In TypeScript it is not validated at all.&lt;/strong> Bearer token verification does exactly three things: strip the prefix, check the required scopes, and require that the expiry is present and not past. A search for audience or key set across the server and middleware packages returns no validation. It is all left in the hands of whichever verifier you plug in.&lt;/p>
&lt;p>&lt;strong>Neither of the two ships a signature verifier with public keys.&lt;/strong> In Python the only example is introspection against the authorisation server, and its audience check sits behind a flag that also comes out false. In TypeScript the interface is empty. That is: the component that validates the token, which is the one holding up the most important normative requirement of the specification, is your own code in both cases.&lt;/p>
&lt;h3 id="the-authentication-challenge">The authentication challenge&lt;/h3>
&lt;p>&lt;strong>Python&lt;/strong> builds the challenge with the error and its description, and adds the metadata URL only if it is configured. It returns 401 with invalid token and 403 with insufficient scope, both from the same function, so both carry the metadata URL. &lt;strong>What it never emits is the scope parameter&lt;/strong>, which revision 2026-07-28 asks to be included, and which is exactly what the client needs in order to know what to ask for.&lt;/p>
&lt;p>&lt;strong>TypeScript does emit the scope&lt;/strong> when there are required scopes, in addition to the metadata URL, and it correctly maps invalid token to 401 and insufficient scope to 403.&lt;/p>
&lt;p>That is, each SDK gets a different half right. The Python one validates better and warns worse; the TypeScript one warns better and does not validate.&lt;/p>
&lt;h3 id="scopes-which-are-not-per-tool">Scopes, which are not per tool&lt;/h3>
&lt;p>In both SDKs scopes are &lt;strong>a static list at the transport mounting level&lt;/strong>, not per tool. There is no per-tool scope declaration and no step-up elevation help on the server side. Emitting a 403 with the specific scope that call requires, which is what the specification describes, is your own code.&lt;/p>
&lt;p>That is the origin of almost all the fine-grained authorisation you have to build, and it is why there is a Part 6.&lt;/p>
&lt;h3 id="which-revision-each-one-announces">Which revision each one announces&lt;/h3>
&lt;p>&lt;strong>Python is already on 2026-07-28.&lt;/strong> The revision appears in the known versions and in the list of modern versions, described as the ones that use the stateless envelope per request. The server discovery method is registered with a default handler, and the method and name headers are validated against the body.&lt;/p>
&lt;p>&lt;strong>TypeScript has it, but in a separate list.&lt;/strong> The latest protocol version constant is still 2025-11-25, because that list is only the one for the initial handshake. The modern era lives in a separate module, with a comment explaining the reason: keeping them deliberately separate so that adding a revision there never leaks a modern version string into a 2025-era handshake.&lt;/p>
&lt;p>And it is worth recalling the mismatch that already came up &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>: LiteLLM 1.102.0 still announces 2025-06-18.&lt;/p>
&lt;h2 id="part-3-building-the-protected-resource">Part 3. Building the protected resource&lt;/h2>
&lt;p>With the above, the list of what you have to write is short and concrete.&lt;/p>
&lt;p>&lt;strong>1. A token verifier that validates the signature and the audience.&lt;/strong> Against the realm&amp;rsquo;s key set, checking issuer, expiry and that the audience contains the MCP server&amp;rsquo;s canonical URL and &lt;strong>only&lt;/strong> things that concern it. Here the side effect of the Keycloak workaround comes in: if a client asks for two scopes from two different resources, out comes a token with two audiences, because each mapper contributes its own to the array. There is no client policy condition or executor in 26.7 that limits the number of audiences per token. The practical defence is the resource&amp;rsquo;s: reject tokens whose audience includes other resources, instead of merely checking that its own is present.&lt;/p>
&lt;p>&lt;strong>2. The complete metadata document&lt;/strong>, with all the authorisation servers you accept, the real catalogue of scopes the resource understands, and the name and documentation filled in. In Python, mounting the route by hand instead of leaving the automatic one. In TypeScript, mounting it, full stop.&lt;/p>
&lt;p>&lt;strong>3. The authentication challenge with the scope.&lt;/strong> In Python you have to add it, because the SDK does not emit it. And you have to take scope hierarchies into account, which is a new requirement of the revision.&lt;/p>
&lt;p>&lt;strong>4. The decision between local validation and introspection.&lt;/strong> Validating the signature locally costs no network, but &lt;strong>revocation does not take effect until the token expires&lt;/strong>: there are no revocation lists for signed tokens. Introspection costs a round trip per request and only confidential clients can invoke it. The reasonable combination is a short token life and introspection on the operations that change state.&lt;/p>
&lt;p>&lt;strong>5. Binding state to the user.&lt;/strong> The new revision is explicit about this, because with protocol sessions gone, state is carried in handlers that travel as an ordinary tool argument. Servers &lt;strong>must&lt;/strong> verify every incoming request and &lt;strong>must not&lt;/strong> treat possession of a handler as authentication; and they &lt;strong>should&lt;/strong> bind the handler to the user on the server side, for example storing the state under a key that combines the user identifier derived from the verified token with the handler, and reject the handler if someone else presents it. It is a change in the way of working compared with the sessions of before.&lt;/p>
&lt;h2 id="part-4-the-hop-from-the-gateway-to-the-mcp-server">Part 4. The hop from the gateway to the MCP server&lt;/h2>
&lt;p>The real shape in an inference platform is not client against MCP server: it is client against gateway, and gateway against MCP server. That second hop is where it is decided whether the architecture is correct.&lt;/p>
&lt;p>The specification leaves no room. The MCP server &lt;strong>must not&lt;/strong> accept or relay tokens that were not issued for it. And if it makes requests to upstream APIs, it may act as an OAuth client of theirs, but &lt;strong>the token it uses there is a different token, issued by the upstream authorisation server&lt;/strong>, and it must not forward the one it received.&lt;/p>
&lt;p>The standard mechanism for that is token exchange, and here you need to know three Keycloak details.&lt;/p>
&lt;p>&lt;strong>First, the permission model changed.&lt;/strong> The old version required fine-grained administration permissions and an explicit exchange authorisation on the target client. The standard version, supported since 26.2, does not require them: it is enough for the requesting client to be confidential and to have the corresponding switch enabled. In exchange there is a surprising condition: &lt;strong>the subject token has to carry the requesting client in its audience&lt;/strong>, unless it exchanges its own token. That is, the gateway needs to appear in the audience of the user token, which is achieved with an audience mapper in a default scope of the gateway. Only then can it narrow the audience to that of the specific MCP server.&lt;/p>
&lt;p>&lt;strong>Second, the audience is a client identifier, not a resource URL.&lt;/strong> The parameter filters audiences, that is to say it narrows them, which is what you want. But it takes the identifier of a client registered in the realm. The practical consequence is that &lt;strong>the MCP server has to be registered as a client, and its identifier should be its canonical URL&lt;/strong>, so that the resulting audience matches what the resource validates. It is a naming trick, and it has to be documented for whoever comes after. The documentation itself acknowledges it: token exchange does not yet support the resource parameter.&lt;/p>
&lt;p>&lt;strong>Third, the subject token type is limited.&lt;/strong> The standard version only accepts access tokens as subject.&lt;/p>
&lt;p>There is also a new experimental feature in 26.7 that adds a parameterised scope type for validating whether the requesting user is authorised to act on behalf of another. Interesting for delegation, but experimental.&lt;/p>
&lt;h3 id="what-the-gateway-does-today">What the gateway does today&lt;/h3>
&lt;p>In LiteLLM 1.102.0 the authentication modes towards MCP servers number twelve, with a default value of none. The ones that matter for this discussion are four.&lt;/p>
&lt;p>&lt;code>oauth2_token_exchange&lt;/code> implements the standard exchange. It sends the correct grant type, the subject token and its type, and &lt;strong>the audience, never the resource&lt;/strong>. The omission is deliberate according to the code itself: fabricating a target risks an invalid target error. It caches the resulting token under a key that combines the subject token with the whole configuration, and with a lifetime equal to the token&amp;rsquo;s minus one minute. And it &lt;strong>blocks, never degrades&lt;/strong>: with no incoming token it returns 401, which the edge turns into a challenge with the resource metadata URL; a rejection from the identity provider returns 401; a gateway configuration error returns 500; a transport failure, 503.&lt;/p>
&lt;p>&lt;code>true_passthrough&lt;/code> and &lt;code>oauth_delegate&lt;/code> forward the client&amp;rsquo;s authorisation header as is. That is token passthrough, by the name the specification gives it, and its legitimate use is narrow: when the token the client presents was already issued for the target MCP server and the gateway is a mere transport. Outside that case, it is non-compliant.&lt;/p>
&lt;p>&lt;code>oauth2_id_jag&lt;/code> is the surprise, and it deserves a part of its own.&lt;/p>
&lt;h2 id="part-5-the-enterprise-extension-which-changes-the-picture">Part 5. The enterprise extension, which changes the picture&lt;/h2>
&lt;p>In June 2026 the MCP project published an authorisation extension that solves a different problem from the one the standard flow solves, and which in an organisation with an identity provider of its own is the real problem.&lt;/p>
&lt;p>The normal flow is per user: each employee authorises each client against each MCP server. It works for consumer applications and it does not work in a company, because onboarding a person requires authorising dozens of services one by one and offboarding requires revoking them one by one.&lt;/p>
&lt;p>&lt;strong>The &lt;code>io.modelcontextprotocol/enterprise-managed-authorization&lt;/code> extension inverts that.&lt;/strong> It is in the stable specifications directory of the extensions repository, it comes from SEP-990, and the flow is this:&lt;/p>
&lt;ol>
&lt;li>The MCP client authenticates the user against the corporate identity provider through the normal flow, and &lt;strong>stores the identity assertion&lt;/strong>, which may be an OpenID identity token or a SAML assertion.&lt;/li>
&lt;li>When the server indicates that enterprise-managed authorisation is needed, the client &lt;strong>exchanges that assertion at the corporate provider for an authorisation grant based on an identity assertion&lt;/strong>. The provider evaluates the organisation&amp;rsquo;s policy there: group membership, roles, conditional access.&lt;/li>
&lt;li>The client presents that grant to the MCP authorisation server and obtains an access token.&lt;/li>
&lt;li>The sentence that defines the extension, verbatim: &lt;strong>the user is not redirected to the authorisation endpoint of the MCP authorisation server&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>The MCP authorisation server validates the signature against the corporate provider&amp;rsquo;s key set, plus audience, issuer and expiry, and uses the subject as the user&amp;rsquo;s stable identifier, with the email as a fallback for linking earlier accounts.&lt;/p>
&lt;p>What that buys is exactly what a compliance file asks for: policy in a single place, an auditable decision at the identity provider, and &lt;strong>centralised revocation that takes effect across all clients at once&lt;/strong>. The employee who loses access stops receiving grants, without touching any server.&lt;/p>
&lt;p>The underlying standard is a draft from the IETF&amp;rsquo;s OAuth working group, under the name of JWT identity assertion authorisation grant, revision 04 of May 2026, signed by people from Okta, Ping Identity and an independent author. It profiles identity chaining across trust domains by combining token exchange with the JWT profile.&lt;/p>
&lt;h3 id="and-here-comes-the-problem">And here comes the problem&lt;/h3>
&lt;p>&lt;strong>Keycloak implements it only halfway.&lt;/strong> It has its own documentation page, behind a feature flag. And three limitations you need in front of you before designing anything:&lt;/p>
&lt;ul>
&lt;li>It only acts as receiver. It accepts assertions issued by an external provider and issues local tokens. Native support for acting as issuer, the documentation says, is not yet fully implemented. In the extension&amp;rsquo;s flow, the issuer is the corporate provider: if that provider is Keycloak, the missing piece is exactly the one you need.&lt;/li>
&lt;li>It is based on draft 01 while the working group is on 04.&lt;/li>
&lt;li>The official documentation says not to use it in production.&lt;/li>
&lt;/ul>
&lt;p>The asymmetry is striking and worth pointing out: Keycloak does not support a standard published eight years ago such as resource indicators, and does partially support a draft from a few months back.&lt;/p>
&lt;h3 id="what-is-already-in-the-gateway-undocumented">What is already in the gateway, undocumented&lt;/h3>
&lt;p>In LiteLLM 1.102.0, the &lt;code>oauth2_id_jag&lt;/code> mode runs the two stages of the flow. In the first it asks the corporate provider for a token exchange with the requested token type specific to this grant, sending the user&amp;rsquo;s identity token as subject and, optionally, audience, &lt;strong>resource&lt;/strong> and scope. In the second it presents the resulting assertion to the resource&amp;rsquo;s authorisation server with the JWT bearer grant type. In both the gateway authenticates as a client with a JWT signed by private key.&lt;/p>
&lt;p>Two operational warnings that come out of the code and are nowhere else:&lt;/p>
&lt;ul>
&lt;li>The subject comes from the caller&amp;rsquo;s identity token, or from the one captured at login. And only the generic OIDC provider captures assertions: with Google, Microsoft or SAML there is none, and &lt;strong>all users fail&lt;/strong>. The code raises a warning when loading the configuration.&lt;/li>
&lt;li>A badly configured server refuses with a 500 instead of falling back to the static credential. It is the right decision, and it is worth knowing before it happens.&lt;/li>
&lt;/ul>
&lt;p>And the main warning: &lt;strong>there is no documentation&lt;/strong>. Not a single file in the repository that mentions this mode. Anyone using it is reading code, like this post.&lt;/p>
&lt;h2 id="part-6-per-tool-authorisation">Part 6. Per-tool authorisation&lt;/h2>
&lt;p>None of the previous pieces answers the question the business ends up asking: whether this person may call this tool with these arguments. The identity provider gives roles and scopes, and with that you do not model a graph of relations, as was already argued &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the previous post&lt;/a>.&lt;/p>
&lt;p>What is published today, with sources:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenFGA&lt;/strong> is the only one with a dedicated product guide, updated on 9 September 2026. The pattern is a tool type with an invocation relation, checked on each call, and a listing query so the client only sees the tools it may invoke. It is a pattern and a modelling language, not a reference implementation.&lt;/li>
&lt;li>&lt;strong>Pomerium&lt;/strong> ships a per-tool policy criterion, with exact, prefix, suffix and list matching.&lt;/li>
&lt;li>&lt;strong>Kong&lt;/strong> offers it as a product, with per-tool control lists.&lt;/li>
&lt;li>&lt;strong>Traefik Hub&lt;/strong> goes as far as parameter-level restrictions, which is the finest published grain.&lt;/li>
&lt;li>&lt;strong>Envoy&lt;/strong> has an MCP filter that extracts protocol attributes for fine-grained access control, declared to be in active development and with no OAuth of its own: the hook is external authorisation towards a policy engine.&lt;/li>
&lt;/ul>
&lt;p>The lowest-friction option in a platform that already has a service mesh is external authorisation against a policy engine. The most expressive one, the relations model.&lt;/p>
&lt;h2 id="part-7-what-still-has-no-standard">Part 7. What still has no standard&lt;/h2>
&lt;p>Two gaps the specification does not cover and that have to be closed from outside.&lt;/p>
&lt;p>&lt;strong>Tool definition change.&lt;/strong> Neither revision 2026-07-28 nor the SDKs pin a hash of the description or of the input schema. The best practices page covers confused deputy, token passthrough, server-side request forgery in discovery, state handler hijacking, server confusion and validation of the authorisation URL scheme, but &lt;strong>there is no integrity or pinning requirement&lt;/strong>. The tool that does it is the Invariant Labs scanner, under the Apache 2.0 licence, which pins hashes to detect definition change after approval and also ships a proxy mode with guardrails. It already came up &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a> and it remains the only answer.&lt;/p>
&lt;p>&lt;strong>The public guidance that does get into this&lt;/strong> is the NSA&amp;rsquo;s on MCP security design considerations, from May 2026, done with Carnegie Mellon&amp;rsquo;s software engineering institute. Two of its observations are directly actionable: that authorisation in MCP is optional and that the specification imposes no requirement on token lifecycle management, so expiry and rotation are left in the organisation&amp;rsquo;s hands; and the warning against dynamic tool discovery with no origin verification and no authorisation checks, which is the most direct acknowledgement of the risk in a state guidance document.&lt;/p>
&lt;h2 id="part-8-who-covers-what">Part 8. Who covers what&lt;/h2>
&lt;p>With all of the above, the decision table for the layer you put in front of the MCP server:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Publishes resource metadata&lt;/th>
&lt;th>Validates audience&lt;/th>
&lt;th>Token exchange&lt;/th>
&lt;th>Per-tool authorisation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Python SDK, automatic route&lt;/td>
&lt;td>Yes, degraded&lt;/td>
&lt;td>&lt;strong>Not by default&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TypeScript SDK&lt;/td>
&lt;td>Does not mount on its own&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traefik Hub (commercial)&lt;/td>
&lt;td>Yes, automatic&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, down to parameter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kong, OAuth plugin for MCP&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, per-tool lists&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pomerium&lt;/td>
&lt;td>Partial&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, per-tool criterion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>agentgateway (Solo.io)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes, by JWT policy&lt;/td>
&lt;td>Yes, including the assertion grant&lt;/td>
&lt;td>Yes, with expressions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>mcp-context-forge (IBM)&lt;/td>
&lt;td>Not in release notes&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, since 1.0.6&lt;/td>
&lt;td>Yes, per-role control&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy, MCP filter&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Via external authorisation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM 1.102.0&lt;/td>
&lt;td>Yes, and authorisation server metadata too&lt;/td>
&lt;td>&lt;strong>Not on the incoming JWT&lt;/strong>&lt;/td>
&lt;td>Yes, and the assertion grant&lt;/td>
&lt;td>Yes, per-key and per-team permissions&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two warnings about that table. The Kong plugin requires a minimum version of 3.12, is in technical preview and needs a licence for its AI edition; it also introduces a breaking change in 3.13, which starts treating all traffic as MCP in order to close a possible authentication bypass. And on oauth2-proxy I found no primary documentation either for or against protected resource mode: I am not claiming it does not have it, I am claiming it is not documented where I could look.&lt;/p>
&lt;h2 id="reference-configuration">Reference configuration&lt;/h2>
&lt;p>&lt;strong>Keycloak: the per-resource scope, optional, with its audience.&lt;/strong> That it is optional and not default is the key piece, because it is what makes the client have to ask for it explicitly and makes it work as a substitute for the resource indicator:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp:inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the MCP server registered as a client &lt;strong>with its canonical URL as identifier&lt;/strong>, so that token exchange produces the right audience:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">kcadm.sh create clients -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">clientId&lt;/span>&lt;span class="o">=&lt;/span>https://mcp.ejemplo.es/inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">enabled&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> -s &lt;span class="nv">publicClient&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">false&lt;/span> -s &lt;span class="nv">consentRequired&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>The gateway, with token exchange instead of token passthrough:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">mcp_servers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">inventario&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;oauth2_token_exchange&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">client_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm-gateway&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">client_secret&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/GW_SECRET&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">token_exchange_endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://sso.ejemplo.es/realms/plataforma/protocol/openid-connect/token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">audience&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">subject_token_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;urn:ietf:params:oauth:token-type:access_token&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">token_exchange_profile&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;rfc8693&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>The resource verifier, which is what nobody hands you ready-made.&lt;/strong> The essential bit is the last check, the one that rejects other resources&amp;rsquo; audiences instead of settling for finding its own:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">RECURSO&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">verificar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">AccessToken&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">claims&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">jwt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">jwks&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">algorithms&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;RS256&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">audience&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># validates that I am there&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;aud&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">aud&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># and that nobody else is: the scopes workaround allows&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># tokens with two audiences, and that reopens the confused deputy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">CLIENTE_GATEWAY&lt;/span>&lt;span class="p">}:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">AccessToken&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;azp&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">expires_at&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;exp&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And when mounting the server, the flag that does not come set:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">auth&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AuthSettings&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource_server_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">validate_token_resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># without this there is only a warning&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">required_scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;mcp:inventario&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Enable audience validation in the Python SDK, or write it into the verifier if it is TypeScript. Without that, the central normative requirement is not met.&lt;/li>
&lt;li>Reject tokens with other resources&amp;rsquo; audiences, not just check that your own is present.&lt;/li>
&lt;li>Mount the metadata document by hand, with all the authorisation servers and the real catalogue of scopes.&lt;/li>
&lt;li>Add the scope parameter to the challenge, in Python, and account for scope hierarchies.&lt;/li>
&lt;li>Register each MCP server as a Keycloak client with its canonical URL as identifier, so that token exchange produces the right audience.&lt;/li>
&lt;li>Replace any token passthrough mode with token exchange, except the narrow case where the token was already issued for the target.&lt;/li>
&lt;li>Check that the gateway appears in the audience of the user token, or the exchange will fail.&lt;/li>
&lt;li>Bind state handlers to the user on the server side, and never treat them as authentication.&lt;/li>
&lt;li>Decide between local validation and introspection knowing that the first delays revocation until expiry, and shorten the token life accordingly.&lt;/li>
&lt;li>Pin a hash of the description and the input schema from outside, because the specification does not cover it.&lt;/li>
&lt;li>Put a policy engine in for per-tool authorisation, and do not try to model it with roles.&lt;/li>
&lt;li>If you are considering the enterprise extension, check first who issues the grant: Keycloak today only knows how to receive it, in experimental and against an earlier draft.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The Python SDK mounts the metadata document on its own, and that is why it looks solved.&lt;/strong> What it publishes carries a single authorisation server and no name and no documentation.&lt;/p>
&lt;p>&lt;strong>The audience validation deprecation warning does not enable anything.&lt;/strong> It warns and carries on behaving as disabled.&lt;/p>
&lt;p>&lt;strong>The TypeScript SDK does not validate the audience at all.&lt;/strong> It is not configuration, it is that there is no code for it.&lt;/p>
&lt;p>&lt;strong>Python&amp;rsquo;s challenge does not carry the scope&lt;/strong>, so a client that receives a 403 does not know what to ask for.&lt;/p>
&lt;p>&lt;strong>The scopes workaround allows two audiences in the same token.&lt;/strong> Each mapper contributes its own, and there is no client policy that limits it. The defence is in the resource.&lt;/p>
&lt;p>&lt;strong>The token exchange audience is a client identifier, not a URL.&lt;/strong> If the MCP server is not registered with its URL as identifier, the resulting audience will not match what the resource validates.&lt;/p>
&lt;p>&lt;strong>Token exchange requires the gateway to be in the audience of the user token.&lt;/strong> It is the condition that wastes the most time the first time round.&lt;/p>
&lt;p>&lt;strong>Dynamic client registration is deprecated&lt;/strong> as of this revision, and the substitute, client identifier metadata documents, is experimental in Keycloak and has an open bug with documents carrying unknown fields, which blocks login with real clients.&lt;/p>
&lt;p>&lt;strong>LiteLLM&amp;rsquo;s identity assertion grant depends on the generic OIDC provider.&lt;/strong> With Google, Microsoft or SAML no assertion is captured and all users fail.&lt;/p>
&lt;p>&lt;strong>Step-up elevation is no longer the server&amp;rsquo;s responsibility.&lt;/strong> Scope accumulation moved to the client in this revision.&lt;/p>
&lt;p>&lt;strong>The authentication elevation signal goes in a 401, not a 403.&lt;/strong> Insufficient scope is 403; asking for a higher authentication level is a different thing and a different code.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The summary of this post fits in one sentence: the identity provider is the cheap half of the problem, and the expensive half is the protected resource, which no SDK hands you ready-made and which in the two official ones comes with audience validation disabled or absent.&lt;/p>
&lt;p>From that comes an order of work. First the token verifier, with the audience validated in both directions, because without it everything else is decoration. Second, the complete metadata document, mounted by hand. Third, replacing any token passthrough with exchange, with the MCP server registered with its URL as identifier. Fourth, a policy engine for what roles do not model.&lt;/p>
&lt;p>And a scheduling decision worth taking with the information in front of you. The enterprise-managed authorisation extension is the right route for an organisation with a provider of its own, it is stable and it has servers in production behind it. What is not ready is Keycloak as issuer. Anyone who wants that flow today has two honest options: delegate the mechanics to a gateway that implements it, or wait. Putting it into production with a feature the official documentation marks as experimental and expressly advises against is not one of them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>: the identity piece from the inside, and the gap this post closes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the basic setup, predating this revision of the specification.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions, its cost and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: the audience validation that does not happen on the gateway side either.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: workload identity with attestation, the other plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its observability&lt;/a>: the protocol and its primitives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a>: the network isolation everything above presupposes.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Especificación MCP, revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">autorización&lt;/a>, &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration">registro de clientes&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization">Extensión de autorización gestionada por la empresa&lt;/a> y su &lt;a href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx">especificación estable&lt;/a>, del SEP-990. Anuncio de 18 de junio de 2026.&lt;/li>
&lt;li>&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/">draft-ietf-oauth-identity-assertion-authz-grant&lt;/a>, revisión 04 de 21 de mayo de 2026, grupo de trabajo OAuth del IETF.&lt;/li>
&lt;li>Código de los SDK oficiales de MCP: &lt;code>python-sdk&lt;/code> y &lt;code>typescript-sdk&lt;/code> 2.0.0-alfa, clonados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración de Keycloak con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/securing-apps/identity-assertion-jwt-authorization-grant">concesión de autorización por aserción de identidad&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>Incidencias de Keycloak &lt;a href="https://github.com/keycloak/keycloak/issues/14355">14355&lt;/a>, &lt;a href="https://github.com/keycloak/keycloak/issues/47117">47117&lt;/a> y &lt;a href="https://github.com/keycloak/keycloak/issues/51039">51039&lt;/a>, y petición de cambios &lt;a href="https://github.com/keycloak/keycloak/pull/35711">35711&lt;/a>.&lt;/li>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">NSA y SEI, Model Context Protocol: Security Design Considerations&lt;/a>, mayo de 2026.&lt;/li>
&lt;li>&lt;a href="https://openfga.dev/docs/use-cases/mcp-server-authorization">OpenFGA, autorización de servidores MCP&lt;/a>, actualizado el 9 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://invariantlabs-ai.github.io/docs/mcp-scan/">mcp-scan de Invariant Labs&lt;/a>, Apache 2.0.&lt;/li>
&lt;li>Documentación de &lt;a href="https://docs.solo.io/agentgateway/2.3.x/mcp/auth/setup/">agentgateway&lt;/a>, &lt;a href="https://doc.traefik.io/traefik-hub/mcp-gateway/mcp">Traefik Hub&lt;/a>, &lt;a href="https://developer.konghq.com/plugins/ai-mcp-oauth2/">Kong&lt;/a> y &lt;a href="https://www.pomerium.com/docs/capabilities/mcp">Pomerium&lt;/a>, consultadas el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>Keycloak in an AI platform: where it fits, what it solves and the two standards MCP requires and it does not implement</title><link>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</link><pubDate>Sat, 12 Sep 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</guid><description>&lt;blockquote>
&lt;p>This post opens the identity block of the operations track. &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The previous one&lt;/a> covered the gateway side: the JWT that does not validate audience, the user who never reaches the trace. This one covers the piece on the other side. Verified against Keycloak 26.7.3, revision 2026-07-28 of the MCP specification and the text of Royal Decree 311/2022 in the BOE.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Keycloak 26.7.3 was released on 31 August 2026&lt;/strong> and is still incubating at the CNCF, which it entered in April 2023. The cadence is four minor releases a year and support in practice covers a single branch: the twenty security bugs fixed in 26.7.3 were not backported to the 26.4, 26.5 or 26.6 branches at the time of release.&lt;/p>
&lt;p>&lt;strong>The Quarkus-based distribution separates build and runtime configuration.&lt;/strong> What is set in the build phase is frozen into the image, and changing it at startup forces a rebuild. In containers, the pattern is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Storage is relational and only relational.&lt;/strong> The alternative engine that was in development was withdrawn in version 25.0. Since 26.0 sessions are persisted to the database by default, which changes the load profile: the dominant cost in the official benchmarks is database CPU.&lt;/p>
&lt;p>&lt;strong>Organisations solves multi-tenancy inside a realm&lt;/strong>, fully supported since 26.0. It groups members, associates email domains and links identity providers per organisation. It is the alternative to replicating realms per customer.&lt;/p>
&lt;p>&lt;strong>The measured bottleneck is password hashing.&lt;/strong> Since 25.0 the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. The official sizing guide gives one vCPU for every 15 password logins per second, against 120 per second for client credentials and for refreshes.&lt;/p>
&lt;p>&lt;strong>Keycloak does not implement two standards the MCP specification marks as mandatory.&lt;/strong> Protected resource metadata belongs to the MCP server, not the authorisation server, so there is nothing to hold against it there. Resource indicators do belong to it and are &lt;strong>not supported&lt;/strong>. Keycloak&amp;rsquo;s own documentation declares partial conformance with the last three MCP revisions for that reason. The practical substitute is scopes plus an audience mapper.&lt;/p>
&lt;p>&lt;strong>There is a recent critical bug worth looking at today.&lt;/strong> &lt;code>CVE-2026-18963&lt;/code>, scored 9.1, allowed resetting any user&amp;rsquo;s password without authenticating, administrators included. Fixed in 26.7.2, released on 19 August 2026.&lt;/p>
&lt;h2 id="you-are-here-the-cross-cutting-layer">You are here: the cross-cutting layer&lt;/h2>
&lt;p>Identity is not a stage of the pipeline, it is a layer that crosses all of them. It shows up in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a> as one of the seven layers of defence, in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the model&amp;rsquo;s chain of trust&lt;/a> as workload identity, in &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the gateway&lt;/a> as token validation, and in &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">the post about authenticating MCP servers&lt;/a> as authorisation server.&lt;/p>
&lt;p>What was missing was the post about the piece itself, and above all the decision of where to put it and what to expect from it.&lt;/p>
&lt;h2 id="the-analogy-the-civil-registry-and-building-permits">The analogy: the civil registry and building permits&lt;/h2>
&lt;p>A small town hall has a population register, which says who lives there, and it has permits, which say who can do what. They are two different things and it is worth not confusing them, because the register is good for the whole town while a permit is good for one specific job on one specific street.&lt;/p>
&lt;p>Keycloak is the register. It knows who each person is, identifies them reliably, knows which groups they belong to and issues a credential document that expires. What it does not do is decide whether this person can modify this particular file: that is the permit, and it lives in each service.&lt;/p>
&lt;p>Confusing the two produces the most expensive design mistake in platforms of this kind, which consists of putting into the token everything that will need to be decided later. The register document grows, gets handed out everywhere, and when a permission changes you have to wait for everyone&amp;rsquo;s document to expire.&lt;/p>
&lt;p>There is a third element the analogy captures well. A credential document issued for presenting at the tax office should not be good for entering the municipal depot. That is the token&amp;rsquo;s audience, and it is the central subject of the second half of this post.&lt;/p>
&lt;h2 id="part-1-what-keycloak-is-today">Part 1. What Keycloak is today&lt;/h2>
&lt;h3 id="project-status">Project status&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Item&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Latest stable version&lt;/td>
&lt;td>26.7.3, released on 31 August 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Previous ones&lt;/td>
&lt;td>26.7.2 (19 August), 26.7.0 (9 July), 26.6.0 (8 April)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cadence&lt;/td>
&lt;td>four minor releases a year&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CNCF&lt;/td>
&lt;td>incubating since 10 April 2023, not graduated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support&lt;/td>
&lt;td>the current branch; earlier ones do not receive fixes reliably&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That last point deserves underlining because it conditions the upgrade policy. The analysis of 26.7.3 shows that its twenty security fixes were not backported to earlier branches at release time. Operating Keycloak means keeping up to date with the current branch, not staying on a &amp;ldquo;stable&amp;rdquo; version for a year.&lt;/p>
&lt;p>Since 26.0 there is a backward compatibility guarantee across minor versions for features marked as fully supported, and breaking changes are opt-in.&lt;/p>
&lt;h3 id="the-internal-architecture-in-five-pieces">The internal architecture in five pieces&lt;/h3>
&lt;p>&lt;strong>Two startup modes.&lt;/strong> The development one enables HTTP, does not resolve the hostname strictly and disables the theme cache. The production one requires a hostname and TLS, or it will not start. Confusing them in production is the first antipattern on the final list.&lt;/p>
&lt;p>&lt;strong>The build phase.&lt;/strong> The build command materialises the optimisations into the image. Build options, such as the database engine, the enabled features or metrics, are frozen. Runtime ones, such as the database password or the hostname, are applied at startup. Changing a build option at startup forces a rebuild with the corresponding latency cost. The correct pattern in containers is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Extensions via SPI.&lt;/strong> Authenticators, protocol mappers, user storage, event listeners, hashing algorithms and secret vaults. They are packaged as JARs and resolved in the build phase.&lt;/p>
&lt;p>&lt;strong>Infinispan cache.&lt;/strong> Embedded by default, with database-based discovery as the recommended mechanism. The discovery alternatives via Kubernetes DNS, TCP and UDP are deprecated. There are local caches, with default capacities of 10,000 entries in several of them, and replicated caches for sessions, client sessions, offline sessions, authentication sessions, login failures and action tokens. Remote mode, with an external Infinispan, is needed for first-generation multi-site deployment.&lt;/p>
&lt;p>&lt;strong>Relational storage.&lt;/strong> JPA over a database. The alternative engine was removed in version 25.0. The databases supported today include PostgreSQL from 14 to 18, MariaDB and MySQL in their extended support versions, SQL Server 2019 and 2022, Oracle 19c and 23.x, and the managed variants Aurora PostgreSQL and Azure SQL.&lt;/p>
&lt;h3 id="the-minimum-conceptual-model">The minimum conceptual model&lt;/h3>
&lt;p>Before touching anything you need to be clear on eight concepts, and the order in which they relate:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Realm&lt;/strong>: the isolation boundary. Its own users, its own signing keys, its own policies. Two realms share nothing.&lt;/li>
&lt;li>&lt;strong>Client&lt;/strong>: an application that asks for tokens. Public if it cannot keep a secret, such as a browser application; confidential if it can, such as a service; and bearer-only if it merely validates tokens without requesting them.&lt;/li>
&lt;li>&lt;strong>Client scope&lt;/strong>: a reusable bundle of scopes and mappers, assigned to clients as default or as optional. It is the unit with which you bound what a token carries.&lt;/li>
&lt;li>&lt;strong>Protocol mapper&lt;/strong>: what projects an attribute, a role or an audience into the token. The audience mapper is the one that solves the central problem of this post.&lt;/li>
&lt;li>&lt;strong>Role&lt;/strong>, realm or client, and &lt;strong>composite role&lt;/strong>, which aggregates others.&lt;/li>
&lt;li>&lt;strong>Group&lt;/strong>, hierarchical, with inherited roles and attributes.&lt;/li>
&lt;li>&lt;strong>Identity provider&lt;/strong>: federation towards another external OIDC or SAML. &lt;strong>User federation&lt;/strong>: LDAP, Active Directory and Kerberos, with synchronisation and attribute mapping.&lt;/li>
&lt;li>&lt;strong>Authentication flow&lt;/strong>: a sequence of executions with required, alternative, conditional or disabled requirements, and subflows. It is where you build a second factor or a check of your own.&lt;/li>
&lt;/ul>
&lt;p>And one feature that changes architectures: &lt;strong>organisations&lt;/strong>, fully supported since 26.0. It solves multi-tenancy inside a single realm, grouping members, associating email domains and linking identity providers per organisation, so that provider discovery is done by the user&amp;rsquo;s email domain. The classic alternative, one realm per customer, multiplies administration and shares no configuration.&lt;/p>
&lt;h2 id="part-2-who-can-authenticate-against-it">Part 2. Who can authenticate against it&lt;/h2>
&lt;p>This is the table you need before drawing anything. The right-hand column is usually the one that decides.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Protocol&lt;/th>
&lt;th>Cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Kubernetes API&lt;/td>
&lt;td>native OIDC, or the structured authentication configuration file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM&lt;/td>
&lt;td>OIDC and JWT, plus UI SSO&lt;/td>
&lt;td>&lt;strong>JWT and SSO above five users: Enterprise&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM&lt;/td>
&lt;td>static key only&lt;/td>
&lt;td>No OIDC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>generic OIDC and Keycloak provider&lt;/td>
&lt;td>SSO free; per-project access control: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Grafana&lt;/td>
&lt;td>generic OAuth &lt;strong>yes in the open edition&lt;/strong>&lt;/td>
&lt;td>Team synchronisation: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Backstage&lt;/td>
&lt;td>generic OIDC and community-maintained Keycloak provider&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open WebUI&lt;/td>
&lt;td>OIDC, with role management by claim&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LibreChat&lt;/td>
&lt;td>generic OIDC, with Keycloak documented&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Argo CD&lt;/td>
&lt;td>native OIDC or Dex, with groups into its RBAC file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MinIO&lt;/td>
&lt;td>OIDC with role assumption via web identity&lt;/td>
&lt;td>See the note below&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two clarifications that change decisions.&lt;/p>
&lt;p>&lt;strong>Kubernetes.&lt;/strong> The modern mechanism is structured authentication configuration, which allows several JWT issuers plus validations and mappings with CEL expressions. The corresponding proposal, KEP-3331, went alpha in 1.29, beta in 1.30 and is &lt;strong>stable since 1.34&lt;/strong>. It is the route to use today, and it replaces the loose API server flags.&lt;/p>
&lt;p>&lt;strong>vLLM has no identity.&lt;/strong> All there is is a static key via environment variable. This is not a defect to fix, it is an architectural fact: the engine sits behind the gateway and its access control is network-based, not identity-based. Any design that exposes vLLM directly to users is giving up per-person traceability.&lt;/p>
&lt;p>About MinIO a note of honesty is in order: the public documentation now redirects to the commercial edition, so the detail of how it maps policies from a claim is verified there and not in the open edition. Anyone depending on that mechanism should confirm it against their own version before designing on top of it.&lt;/p>
&lt;h2 id="part-3-humans-and-machines">Part 3. Humans and machines&lt;/h2>
&lt;p>They are two different problems and it is worth solving them separately.&lt;/p>
&lt;h3 id="people">People&lt;/h3>
&lt;p>Authorization code with PKCE, with the S256 method. The MCP specification requires it when the client is technically capable, and it goes further: it mandates &lt;strong>rejecting the flow&lt;/strong> if the authorisation server metadata does not publish the supported challenge methods.&lt;/p>
&lt;h3 id="workloads-with-no-person-behind-them">Workloads with no person behind them&lt;/h3>
&lt;p>Here the obvious option is client credentials with a Keycloak service account, authenticated with a secret or, better, with a JWT signed by private key.&lt;/p>
&lt;p>But there is a less known and better option for workloads inside the cluster: &lt;strong>federated client authentication&lt;/strong>, introduced in January 2026 under the banner of doing away with secrets. It lets a client authenticate with a token issued by an external provider, and one of those providers is Kubernetes. The announcement placed full support for the Kubernetes service account variant and for OIDC in version 26.6; the SPIFFE variant, by contrast, expressly says it will stay in preview until the SPIFFE client authentication standard is finalised at the IETF.&lt;/p>
&lt;p>That means a pod can authenticate against Keycloak with the token the kubelet already projects into it, with no additional secret to rotate. It is worth confirming the exact status and the flag name against the release notes of whichever version you have deployed, because the feature is recent and has changed status between 26.5 and 26.6. It is preferable to token exchange, whose standard version is supported but is internal-to-internal only, while the old one, which covered external-to-internal and impersonation, is deprecated and in preview.&lt;/p>
&lt;h3 id="keycloak-against-spiffe">Keycloak against SPIFFE&lt;/h3>
&lt;p>The question always comes up and the answer is that they solve different things with a small overlap.&lt;/p>
&lt;p>SPIRE attests the node and the process, and issues short-lived identity documents with no prior secret. Keycloak does not attest workloads: it trusts a credential someone had to place there, except in the federated authentication case just mentioned, where the one attesting is Kubernetes. In exchange, Keycloak federates people, issues tokens with business data and governs consent, things SPIFFE does not do.&lt;/p>
&lt;p>The practical rule: SPIFFE for service-to-service identity inside the perimeter, Keycloak for anything that has a person behind it or crosses the perimeter. The SPIFFE detail is in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the close of the chain of trust series&lt;/a>.&lt;/p>
&lt;h2 id="part-4-mcp-and-the-two-missing-standards">Part 4. MCP, and the two missing standards&lt;/h2>
&lt;p>This part has the most consequences and is the worst documented outside the primary sources.&lt;/p>
&lt;h3 id="what-the-specification-requires">What the specification requires&lt;/h3>
&lt;p>From revision 2026-07-28, with the normative words as they appear:&lt;/p>
&lt;ul>
&lt;li>The MCP server &lt;strong>must&lt;/strong> implement RFC 9728 protected resource metadata, and the client &lt;strong>must&lt;/strong> use it to discover the authorisation server.&lt;/li>
&lt;li>The client &lt;strong>must&lt;/strong> implement RFC 8707 resource indicators, including the &lt;code>resource&lt;/code> parameter &lt;strong>in both the authorisation request and the token request&lt;/strong>, and &lt;strong>must&lt;/strong> send it regardless of whether the authorisation server supports it.&lt;/li>
&lt;li>The MCP server &lt;strong>must&lt;/strong> validate that the access token was issued specifically for it as the audience, and &lt;strong>must not&lt;/strong> accept or relay any other token.&lt;/li>
&lt;li>Authorisation servers and clients &lt;strong>should&lt;/strong> support client identifier metadata documents. RFC 7591 dynamic registration becomes optional and is declared deprecated, retained for compatibility.&lt;/li>
&lt;/ul>
&lt;p>The revision also adds the issuer in the authorisation response, from RFC 9207, and a stateless model with state handlers where it is expressly stated that holding a state handler is not equivalent to being authenticated.&lt;/p>
&lt;h3 id="what-keycloak-supports">What Keycloak supports&lt;/h3>
&lt;p>From its own MCP integration guide:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Standard&lt;/th>
&lt;th>Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>OAuth 2.1 and authorisation server metadata&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 9207, issuer in the response&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 7591, dynamic registration&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Client identifier metadata document&lt;/td>
&lt;td>Supported, &lt;strong>experimental&lt;/strong>, after enabling the feature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 8707, resource indicators&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Not supported&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 9728, protected resource metadata&lt;/strong>&lt;/td>
&lt;td>Out of scope, it belongs to the MCP server&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The conformance declared by the project itself is &amp;ldquo;partially supported, without resource indicators&amp;rdquo; for the last three MCP revisions.&lt;/p>
&lt;p>Two practical conclusions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The protected resource metadata document is published by the MCP server or a proxy in front of it&lt;/strong>, never by Keycloak. That is correct and there is nothing to fix, but you have to remember to implement it, because the client needs it to discover where to go.&lt;/li>
&lt;li>&lt;strong>Without resource indicators, the audience is bounded with scopes.&lt;/strong> The official route is to use the &lt;code>scope&lt;/code> parameter instead of &lt;code>resource&lt;/code>, and to hang from that client scope an audience mapper with the MCP server URL as the included custom audience.&lt;/li>
&lt;/ol>
&lt;p>It is a workaround, it works, and it has to be documented in the design because anyone coming after will look for the standard parameter and will not find it.&lt;/p>
&lt;h2 id="part-5-the-confused-deputy">Part 5. The confused deputy&lt;/h2>
&lt;p>With the above on the table, the classic mistake becomes obvious on its own. A token with a generic audience turns any compromised service into a master key: whoever obtains the token a user presented to the dashboard can present it to the gateway, and from there to the MCP server.&lt;/p>
&lt;p>The MCP specification is explicit in three sentences worth quoting verbatim in any architecture document:&lt;/p>
&lt;ul>
&lt;li>MCP servers &lt;strong>must not&lt;/strong> accept tokens that were not explicitly issued for them.&lt;/li>
&lt;li>If the MCP server calls upstream APIs, it &lt;strong>must not&lt;/strong> forward the token it received from the client.&lt;/li>
&lt;li>MCP proxies that use static client identifiers &lt;strong>must&lt;/strong> obtain user consent for each dynamically registered client before forwarding to third-party authorisation servers.&lt;/li>
&lt;/ul>
&lt;p>Translated into the specific figure this track has been dealing with: &lt;strong>the gateway must not forward the user&amp;rsquo;s JWT to third-party MCP servers&lt;/strong>. It has to obtain a different token with the right audience, or act as an OAuth client in its own right. By default, as already documented &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>, the behaviour is to use the server&amp;rsquo;s own credential, which is the correct one; there are two modes that forward the caller&amp;rsquo;s token and both have a name in the specification.&lt;/p>
&lt;h2 id="part-6-two-hours-of-task-against-five-minutes-of-token">Part 6. Two hours of task against five minutes of token&lt;/h2>
&lt;p>The specification pushes towards short-lived tokens and mandatory refresh token rotation in public clients. A two-hour agentic task comfortably exceeds the life of an access token and may also exceed session idle time if nobody refreshes.&lt;/p>
&lt;p>Three real options, with their cost:&lt;/p>
&lt;p>&lt;strong>Offline tokens.&lt;/strong> They survive session expiry, and expire through their own idle timeout or through a maximum if that limit is enabled. The risk is obvious: a near-permanent credential, hard to inventory. And the MCP specification adds that MCP servers &lt;strong>should not&lt;/strong> include that scope either in the authentication header or in the supported scopes of their metadata.&lt;/p>
&lt;p>&lt;strong>Automatic refresh in the client.&lt;/strong> It is the right answer when there is a client that can implement it, with refresh token rotation and secure storage. For an agent with no person behind it, it is better to renew via client credentials.&lt;/p>
&lt;p>&lt;strong>Long-lived virtual keys in the gateway.&lt;/strong> It works, and it is what most people end up doing. The price has to be written down in the risk analysis: identity ends up decoupled from the provider, so deactivating someone in Keycloak does not invalidate their key, and traceability back to the real person depends on the discipline with which the keys were created. The levers that compensate are expiry, automatic rotation and a mandatory alias, covered &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">in the virtual keys post&lt;/a>.&lt;/p>
&lt;p>A note of honesty about the default values. The numbers going around (five minutes of access token, thirty of session idle time, ten hours of maximum) do not appear published in the administration guide and I did not locate them in the code. Treat them as a console observation and confirm them in your own realm before quoting them in a document.&lt;/p>
&lt;h2 id="part-7-sizing-and-operating">Part 7. Sizing and operating&lt;/h2>
&lt;h3 id="on-kubernetes">On Kubernetes&lt;/h3>
&lt;p>The operator exposes three custom resources: the server, the realm import and the OIDC client. Probes and metrics live on &lt;strong>management port 9000&lt;/strong>, with the health and readiness paths, and metrics in Prometheus format provided the corresponding option was enabled in the build phase. That port is not exposed publicly.&lt;/p>
&lt;p>For high availability there are three shapes. A single cluster spread across zones, with no additional infrastructure. First-generation multi-cluster, with an external load balancer and an external Infinispan at each site. And second-generation multi-cluster, introduced in 26.7.0 and still in preview, which connects clusters &lt;strong>without external Infinispan&lt;/strong>, using the synchronously replicated database as the source of truth, with less cache and more database load.&lt;/p>
&lt;h3 id="the-official-sizing-figures">The official sizing figures&lt;/h3>
&lt;p>From the CPU and memory sizing concepts guide, which is still published:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>1 vCPU for every 15 password logins per second&lt;/strong>, tested up to 300 per second.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 client credential grants per second&lt;/strong>, tested up to 2,000.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 token refresh requests per second&lt;/strong>, tested up to 435.&lt;/li>
&lt;li>Leave a &lt;strong>150 % CPU headroom&lt;/strong> for spikes, startup and failover.&lt;/li>
&lt;li>&lt;strong>1,250 MB of memory per pod&lt;/strong> with realm data and 10,000 cached sessions. Keycloak allocates 70 % of the limit to the heap and around 300 MB outside it, so the limit comes from subtracting those 300 MB from the expected usage and dividing by 0.7.&lt;/li>
&lt;/ul>
&lt;p>The stated assumptions behind those figures include Argon2 with five iterations, sessions in the database and the default cache of 10,000 entries.&lt;/p>
&lt;p>The difference between 15 and 120 is the whole story of Keycloak&amp;rsquo;s performance: &lt;strong>the cost is in password hashing&lt;/strong>. Since 25.0, the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. Before it was PBKDF2, whose iterations were multiplied by ten in version 24. In FIPS mode PBKDF2 is still used.&lt;/p>
&lt;p>From that comes an architectural consequence for an AI platform: &lt;strong>machine workloads, which go through client credentials, cost eight times less than a human login&lt;/strong>. Sizing by number of services is cheap; sizing by morning login peaks is the expensive part.&lt;/p>
&lt;h3 id="the-benchmark">The benchmark&lt;/h3>
&lt;p>From the 26.4 performance report, published on 1 October 2025: up to 2,000 logins per second and 10,000 refreshes per second with three pods, 74 vCPU in total and 8 GB per pod, over an Aurora PostgreSQL database, with almost linear vertical scaling.&lt;/p>
&lt;p>Two useful findings from that report. With 20 ms of round-trip time between sites, the 99th percentile rises from 51 to 130 ms, and the project &lt;strong>advises against deploying across different regions&lt;/strong>. And raising the cache from 10,000 to 200,000 entries brought the database CPU peak down from 77.77 % to 63.77 %, in exchange for somewhat longer garbage collection pauses.&lt;/p>
&lt;h3 id="security">Security&lt;/h3>
&lt;p>The urgent matter, if the deployment is below 26.7.2:&lt;/p>
&lt;p>&lt;strong>&lt;code>CVE-2026-18963&lt;/code>, score 9.1, critical.&lt;/strong> A bypass of the email action token in the credential reset flow allowed resetting any user&amp;rsquo;s password with no prior authentication, administrators included. Fixed in 26.7.2, released on 19 August 2026. The temporary mitigation, if you cannot upgrade, is to disable password recovery in all realms. No known exploitation was recorded as of the end of August.&lt;/p>
&lt;p>In 26.7.3 there are another nineteen, among them the missing hostname verification in the TLS certificate towards LDAP with a score of 8.8, two tenant restriction bypasses in token exchange with Microsoft and Google brokers, and two access control bypasses in organisations.&lt;/p>
&lt;p>Official hardening comes down to four decisions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Strict hostname&lt;/strong>, which is the default value since the second generation of that configuration. The server does not infer the URL from headers, which prevents poisoning email links and redirects.&lt;/li>
&lt;li>&lt;strong>Administration console on a different hostname&lt;/strong>, and network-restricted.&lt;/li>
&lt;li>&lt;strong>Proxy headers declared explicitly&lt;/strong>, trusting only a controlled reverse proxy.&lt;/li>
&lt;li>&lt;strong>Separate management port, not exposed.&lt;/strong>&lt;/li>
&lt;/ol>
&lt;h2 id="part-8-the-mapping-to-the-ens">Part 8. The mapping to the ENS&lt;/h2>
&lt;p>The codes and titles below are verified against the official text of Royal Decree 311/2022 published in the BOE, Annex II.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Code&lt;/th>
&lt;th>Literal title&lt;/th>
&lt;th>What a central IdP contributes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>op.acc.1&lt;/td>
&lt;td>Identificación&lt;/td>
&lt;td>A unique identifier per entity, user or process that gains access&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.2&lt;/td>
&lt;td>Requisitos de acceso&lt;/td>
&lt;td>Permissions are granted by role and group, not by loose credential&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.3&lt;/td>
&lt;td>Segregación de funciones y tareas&lt;/td>
&lt;td>It supports it with roles, but the control is organisational&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.4&lt;/td>
&lt;td>Proceso de gestión de derechos de acceso&lt;/td>
&lt;td>Least privilege and a specific remote access policy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.5&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios externos)&lt;/td>
&lt;td>Flows and factors configurable per flow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.6&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios de la organización)&lt;/td>
&lt;td>Reinforcements required already at the low level&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.8&lt;/td>
&lt;td>Registro de la actividad&lt;/td>
&lt;td>It contributes part of the events, not all of them&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.10&lt;/td>
&lt;td>Protección de claves criptográficas&lt;/td>
&lt;td>Management and rotation of the realm signing keys&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two nuances an auditor is going to look for.&lt;/p>
&lt;p>The first, about &lt;strong>op.acc.1&lt;/strong>: the text requires every entity that accesses the system, whether user or process, to have a unique identifier. That includes agents. A virtual key shared between several agents does not comply, and that is exactly what usually happens when JWT authentication falls outside the budget.&lt;/p>
&lt;p>The second, about &lt;strong>op.exp.8&lt;/strong>: the control requires recording the identifier of the user or entity associated with the event, the date and time, what information it is performed on, the type and the result. Keycloak contributes the authentication events. The usage events are contributed by the gateway and the MCP servers, and there the two already documented gaps reappear: &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">adding and removing MCP servers generates no audit log&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the trace user is set by the client&lt;/a>.&lt;/p>
&lt;p>The correspondence between control and product in this table is my own interpretation and must be validated by the certification body. The codes and titles are not: those are literal from the BOE.&lt;/p>
&lt;h2 id="part-9-the-configuration-to-leave-written-down">Part 9. The configuration to leave written down&lt;/h2>
&lt;p>Three concrete pieces, so as not to stay at the level of concepts.&lt;/p>
&lt;p>&lt;strong>Hardened startup&lt;/strong>, as container arguments:&lt;/p>
&lt;pre tabindex="0">&lt;code>kc.sh start --optimized \
--hostname=https://sso.ejemplo.es \
--hostname-admin=https://sso-admin.interna.ejemplo.es \
--proxy-headers=xforwarded \
--health-enabled=true \
--metrics-enabled=true \
--http-management-port=9000 \
--cache=ispn --cache-stack=jdbc-ping \
--db=postgres
&lt;/code>&lt;/pre>&lt;p>The metrics, health, cache and database options are build options: they go into the image, and passing them here forces a rebuild if they do not match.&lt;/p>
&lt;p>&lt;strong>The client scope with audience&lt;/strong>, which is the workaround forced by the lack of resource indicators. Created with the administration tool:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># one scope per protected resource&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp-inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># the mapper that sets the token audience&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client asks for that scope and receives a token whose audience is only that MCP server. The MCP server validates the audience and rejects any other token, as the specification requires.&lt;/p>
&lt;p>&lt;strong>Realm import via GitOps&lt;/strong>, with the operator&amp;rsquo;s custom resource, so that the configuration does not live only in the console:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">k8s.keycloak.org/v2beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">KeycloakRealmImport&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">identidad&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">keycloakCRName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sso&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, the recovery email is attack surface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resetPasswordAllowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bruteForceProtected&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sslRequired&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">all&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="part-10-what-keycloak-does-not-solve">Part 10. What Keycloak does not solve&lt;/h2>
&lt;p>&lt;strong>Fine-grained authorisation over resources.&lt;/strong> The question &amp;ldquo;can this person see this particular trace&amp;rdquo; is a relation between objects, not a role. That problem is solved by Zanzibar-style engines, with OpenFGA and its object, relation and user tuples, or a policy language such as Cedar. Keycloak gives roles and scopes, and with those you do not model a graph of relations.&lt;/p>
&lt;p>&lt;strong>Machine secrets.&lt;/strong> Storage credentials, provider keys, database passwords. That is a secrets manager, covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Cryptographic workload identity with attestation.&lt;/strong> That is SPIFFE and SPIRE. In Keycloak it exists as an identity provider in preview, which is a different thing.&lt;/p>
&lt;p>&lt;strong>Authorisation inside the model.&lt;/strong> Which MCP tool an agent may invoke, and with what arguments. The specification offers scopes, the insufficient scope error and step-up elevation, but the per-tool and per-argument decision is taken by the MCP server or a policy engine, not by the identity provider.&lt;/p>
&lt;h2 id="antipatterns">Antipatterns&lt;/h2>
&lt;ol>
&lt;li>Forwarding the user&amp;rsquo;s token from the gateway to third-party MCP servers. Explicitly forbidden by the specification.&lt;/li>
&lt;li>Static client identifier with dynamic registration and no per-client consent, which is the exact recipe for the confused deputy.&lt;/li>
&lt;li>General scopes, of the full-access kind, and publishing the entire catalogue of supported scopes in the metadata.&lt;/li>
&lt;li>Treating a state handler as authentication, which the new revision expressly forbids.&lt;/li>
&lt;li>Following OAuth discovery towards any URL, which opens the door to requests towards internal addresses. It also applies to the authorisation server when it downloads a client metadata document.&lt;/li>
&lt;li>Trust headers badly exposed in chat front ends. The Open WebUI documentation warns that an incorrect configuration allows authenticating as any user.&lt;/li>
&lt;li>Starting in development mode in production, with the hostname not resolved strictly.&lt;/li>
&lt;li>A single shared static key as the only control between the gateway and the engine.&lt;/li>
&lt;/ol>
&lt;h2 id="startup-checklist">Startup checklist&lt;/h2>
&lt;ol>
&lt;li>Check the version. Below 26.7.2 there is a critical password reset bug.&lt;/li>
&lt;li>Build the image with the build phase done and start with the optimised flag.&lt;/li>
&lt;li>Strict hostname, administration console on another hostname, separate management port and not exposed.&lt;/li>
&lt;li>Decide between organisations and one realm per tenant before creating the second tenant.&lt;/li>
&lt;li>One client scope per resource, with an audience mapper, because resource indicators are not supported.&lt;/li>
&lt;li>In the gateway, define the two audience and issuer variables. Without them none of that is validated.&lt;/li>
&lt;li>For workloads inside the cluster, look at the Kubernetes identity provider before handing out client secrets.&lt;/li>
&lt;li>Size by login peaks, not by number of services: the difference is eightfold.&lt;/li>
&lt;li>Export the Keycloak events to the same place the gateway&amp;rsquo;s go, because the activity logging control needs them together.&lt;/li>
&lt;li>Write into the design what the system does with a two-hour task, before someone solves the problem with an offline token.&lt;/li>
&lt;/ol>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Keycloak is a mature piece that solves one bounded problem well: knowing who the caller is, and saying so verifiably in a document that expires. In a sovereign inference platform that is worth a lot, because the alternative is an inventory of static keys spread across ten services that no audit can walk through.&lt;/p>
&lt;p>What is not advisable is asking more of it. Fine-grained authorisation lives in each service, workload identity with attestation lives in SPIFFE, and the decision of which tool an agent may call lives in the MCP server.&lt;/p>
&lt;p>And there is one concrete gap worth noting down, because it does not close on its own: the MCP specification marks two standards as mandatory, and of the two that would fall to an authorisation server, resource indicators are not implemented. The workaround with scopes and an audience mapper works, but it is a workaround, and whoever comes after will look for the standard parameter. Leaving it written in the design saves an afternoon.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: how the gateway validates the tokens this piece issues, and the two variables you have to define.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the concrete setup for MCP servers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: SPIFFE, SPIRE and workload identity with attestation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: where identity fits among the seven layers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a>: the alternative when JWT falls outside the budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a>: the follow-up: the protected resource side you have to build, token exchange and the enterprise authorisation extension.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.keycloak.org/2026/08/keycloak-2673-released">Keycloak 26.7.3&lt;/a>, 31 de agosto de 2026, y &lt;a href="https://www.keycloak.org/downloads">descargas&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/server/configuration">Configuración del servidor&lt;/a>, &lt;a href="https://www.keycloak.org/server/caching">caché&lt;/a>, &lt;a href="https://www.keycloak.org/server/db">bases de datos&lt;/a> y &lt;a href="https://www.keycloak.org/server/hostname">nombre de host&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/2026/01/federated-client-authentication">autenticación federada de clientes&lt;/a>, 27 de enero de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/high-availability/multi-cluster/concepts-memory-and-cpu-sizing">Dimensionado de CPU y memoria&lt;/a> e &lt;a href="https://www.keycloak.org/high-availability/introduction">introducción a alta disponibilidad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/2025/10/keycloak-benchmark">Bancos de pruebas de rendimiento de Keycloak 26.4&lt;/a>, 1 de octubre de 2025.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">Especificación de MCP, revisión 2026-07-28, autorización&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-auth/3331-structured-authentication-configuration">KEP-3331, configuración estructurada de autenticación&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.boe.es/diario_boe/xml.php?id=BOE-A-2022-7191">Real Decreto 311/2022, Anexo II&lt;/a>.&lt;/li>
&lt;li>Registro de &lt;code>CVE-2026-18963&lt;/code> en la base de datos de Red Hat y notas de la versión 26.7.3 en GitHub.&lt;/li>
&lt;li>&lt;a href="https://www.cncf.io/projects/keycloak/">CNCF, proyecto Keycloak&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>When MCP grows up: giving it authentication with Keycloak</title><link>https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/</link><pubDate>Thu, 18 Jun 2026 08:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/</guid><description>&lt;p>When a child is small, it is enough to make sure they do not hurt themselves. You put a gate on the stairs, you cover the sockets and little else. But the child grows. They start going out alone, handling money, making decisions that have consequences. And then raising them stops being &amp;ldquo;don&amp;rsquo;t let them fall&amp;rdquo; and turns into something far more demanding: teaching them to identify themselves, to ask permission, to know how far they can go and to account for what they do.&lt;/p>
&lt;p>That is exactly what has happened with our MCP servers.&lt;/p>
&lt;h2 id="mcp-is-no-longer-a-toy">MCP is no longer a toy&lt;/h2>
&lt;p>Not long ago, a &lt;a href="https://modelcontextprotocol.io/">Model Context Protocol&lt;/a> server was an experiment running on someone&amp;rsquo;s laptop so a model could read a couple of files or call an internal API. It did not matter who used it: it was us, locally, with nothing at stake.&lt;/p>
&lt;p>That is over. MCP servers have become infrastructure pieces that AI uses to touch real systems. And, like any child who grows up, they have developed new needs they did not have before:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Multitenancy&lt;/strong>: there is no longer a single user but many, and one user&amp;rsquo;s data cannot be visible to another.&lt;/li>
&lt;li>&lt;strong>Cost control&lt;/strong>: every model call consumes tokens, compute and, sometimes, paid APIs. You have to measure, attribute and set limits.&lt;/li>
&lt;li>&lt;strong>Authentication and authorisation&lt;/strong>: knowing who is on the other side, what permissions they hold and which specific server their credential is good for.&lt;/li>
&lt;/ul>
&lt;p>These are three separate fronts. Today we focus on the third, which is the one that opens the door to the other two: &lt;strong>authentication&lt;/strong>. Without knowing who is calling, there is no tenant to separate and no cost to attribute.&lt;/p>
&lt;h2 id="why-mcp-authentication-is-a-problem-with-a-name-of-its-own">Why MCP authentication is a problem with a name of its own&lt;/h2>
&lt;p>The good news is that nothing has to be invented here. The MCP specification did not pull a security mechanism of its own out of a hat: it builds on &lt;strong>OAuth&lt;/strong>, the same standard that has been behind half the internet&amp;rsquo;s &amp;ldquo;Sign in with…&amp;rdquo; for years. MCP defines how an MCP server acts as a &lt;em>protected resource&lt;/em> and delegates credential issuance to an &lt;em>authorisation server&lt;/em>.&lt;/p>
&lt;p>And that authorisation server can be &lt;a href="https://www.keycloak.org/">Keycloak&lt;/a>, the open source identity manager from the Cloud Native Computing Foundation that you probably already run for the rest of your applications. The idea is not to add one more identity silo just because we are now talking to models: reuse the one that already governs your users.&lt;/p>
&lt;p>The important nuance is that &lt;strong>MCP is not a single frozen standard&lt;/strong> but a living specification with several versions, and each one asks for different things. As of today four coexist:&lt;/p>
&lt;ul>
&lt;li>&lt;code>2025-11-25&lt;/code> (the latest)&lt;/li>
&lt;li>&lt;code>2025-06-18&lt;/code>&lt;/li>
&lt;li>&lt;code>2025-03-26&lt;/code>&lt;/li>
&lt;li>&lt;code>2024-11-05&lt;/code> (the initial one, which did not even contemplate authorisation)&lt;/li>
&lt;/ul>
&lt;p>Just as with raising a child what they need at 6 is not what they need at 16, what Keycloak has to satisfy depends on the MCP version you work with. Let us look at it.&lt;/p>
&lt;h2 id="what-mcp-demands-from-an-authorisation-server-and-what-keycloak-provides">What MCP demands from an authorisation server (and what Keycloak provides)&lt;/h2>
&lt;p>The specification lists a series of OAuth standards the authorisation server must support, with different levels of requirement (&lt;code>MUST&lt;/code>, &lt;code>SHOULD&lt;/code>, &lt;code>MAY&lt;/code>). This is the picture, cross-referenced with what Keycloak supports:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Standard&lt;/th>
&lt;th>2025-11-25&lt;/th>
&lt;th>2025-06-18&lt;/th>
&lt;th>2025-03-26&lt;/th>
&lt;th>Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>OAuth 2.1 Authorization Framework (draft)&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OAuth 2.0 Authorization Server Metadata (RFC 8414)&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Resource Indicators for OAuth 2.0 (RFC 8707)&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>MUST&lt;/td>
&lt;td>—&lt;/td>
&lt;td>&lt;strong>Not supported&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dynamic Client Registration (RFC 7591)&lt;/td>
&lt;td>MAY&lt;/td>
&lt;td>SHOULD&lt;/td>
&lt;td>SHOULD&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OAuth Client ID Metadata Document (draft)&lt;/td>
&lt;td>SHOULD&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;td>Supported (experimental)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>One clarification: MCP also adopts &lt;em>OAuth 2.0 Protected Resource Metadata&lt;/em> (RFC 9728), but that standard is the &lt;strong>MCP server&amp;rsquo;s&lt;/strong> business, not the authorisation server&amp;rsquo;s, so it does not appear in the Keycloak table.&lt;/p>
&lt;p>If we take as our conformance criterion that Keycloak &amp;ldquo;supports&amp;rdquo; a version when it meets all its &lt;code>MUST&lt;/code> and &lt;code>SHOULD&lt;/code> requirements, the result is this:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>MCP version&lt;/th>
&lt;th>Conformance&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>2025-03-26&lt;/code>&lt;/td>
&lt;td>&lt;strong>Supported&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>2025-06-18&lt;/code>&lt;/td>
&lt;td>Partial, without Resource Indicators (RFC 8707)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>2025-11-25&lt;/code>&lt;/td>
&lt;td>Partial, without Resource Indicators (RFC 8707)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The only piece Keycloak lacks today is &lt;strong>Resource Indicators for OAuth 2.0 (RFC 8707)&lt;/strong>, the &lt;code>resource&lt;/code> parameter that ties a token to a specific server. The Keycloak community already has support for it on the roadmap; in the meantime there is a perfectly valid workaround we look at below. Following the analogy, it is that subject the child still struggles with but can pass with a study trick until they fully mature.&lt;/p>
&lt;h2 id="down-to-work-setting-up-keycloak-for-your-mcp-version">Down to work: setting up Keycloak for your MCP version&lt;/h2>
&lt;h3 id="for-mcp-2025-03-26">For MCP 2025-03-26&lt;/h3>
&lt;p>Nothing special has to be configured. This version does not require the &lt;code>resource&lt;/code> parameter, so Keycloak complies out of the box. The child is still small and the gate on the stairs is enough.&lt;/p>
&lt;h3 id="for-mcp-2025-06-18-and-2025-11-25-tying-the-token-to-its-audience">For MCP 2025-06-18 and 2025-11-25: tying the token to its audience&lt;/h3>
&lt;p>Here is where it gets interesting. For security, these versions require an access token to be &lt;strong>bound to the audience&lt;/strong> it was issued for. In plain terms:&lt;/p>
&lt;ul>
&lt;li>The MCP client &lt;strong>must&lt;/strong> include the &lt;code>resource&lt;/code> parameter (from RFC 8707) in the authorisation and token requests, with the value that identifies the MCP server it is going to use.&lt;/li>
&lt;li>The MCP server &lt;strong>must&lt;/strong> validate that the tokens it receives were issued specifically for it.&lt;/li>
&lt;/ul>
&lt;p>This prevents a stolen or reused token from getting into a server other than the intended one. The problem, as we said, is that &lt;strong>Keycloak does not yet understand the &lt;code>resource&lt;/code> parameter&lt;/strong>.&lt;/p>
&lt;p>The solution until native RFC 8707 support arrives is to use OAuth&amp;rsquo;s &lt;strong>&lt;code>scope&lt;/code>&lt;/strong> parameter to achieve the same effect. Picture this situation:&lt;/p>
&lt;ul>
&lt;li>The MCP server is at &lt;code>https://example.com/mcp&lt;/code>.&lt;/li>
&lt;li>It supports three scopes: &lt;code>mcp:tools&lt;/code>, &lt;code>mcp:prompts&lt;/code> and &lt;code>mcp:resources&lt;/code>.&lt;/li>
&lt;li>The client asks for a token with &lt;code>resource = https://example.com/mcp&lt;/code> and a combination of those scopes.&lt;/li>
&lt;li>We want Keycloak to issue a token whose &lt;code>aud&lt;/code> (audience) is precisely &lt;code>https://example.com/mcp&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>To achieve it, we configure Keycloak like this, repeating the pattern for each scope:&lt;/p>
&lt;ul>
&lt;li>Create a &lt;strong>client scope&lt;/strong> &lt;code>mcp:tools&lt;/code> of type &lt;em>Optional&lt;/em> and add an &lt;strong>Audience mapper&lt;/strong> whose &lt;em>Included Custom Audience&lt;/em> is &lt;code>https://example.com/mcp&lt;/code>.&lt;/li>
&lt;li>Create a &lt;strong>client scope&lt;/strong> &lt;code>mcp:prompts&lt;/code> of type &lt;em>Optional&lt;/em> with its &lt;strong>Audience mapper&lt;/strong> pointing at &lt;code>https://example.com/mcp&lt;/code>.&lt;/li>
&lt;li>Create a &lt;strong>client scope&lt;/strong> &lt;code>mcp:resources&lt;/code> of type &lt;em>Optional&lt;/em> with its &lt;strong>Audience mapper&lt;/strong> pointing at &lt;code>https://example.com/mcp&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>The key is that the &lt;em>Included Custom Audience&lt;/em> of each client scope is &lt;strong>identical&lt;/strong> to the value of the request&amp;rsquo;s &lt;code>resource&lt;/code> parameter and to the MCP server URL. With this, if the client asks for a token with those three scopes, Keycloak issues something like:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;aud&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;https://example.com/mcp&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;mcp:resources mcp:tools mcp:prompts&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The MCP server can now check the &lt;code>aud&lt;/code> and reject any token that was not meant for it. We have tied the credential to its recipient without waiting for RFC 8707.&lt;/p>
&lt;h3 id="if-you-use-mcp-inspector">If you use MCP Inspector&lt;/h3>
&lt;p>&lt;a href="https://github.com/modelcontextprotocol/inspector">MCP Inspector&lt;/a> is the official tool for debugging MCP servers, and it registers clients dynamically against Keycloak by running JavaScript from its backend. For it to work you have to adjust &lt;strong>CORS&lt;/strong> in the client registration &lt;em>anonymous access policies&lt;/em>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Allowed Client Scopes&lt;/strong>: include the scopes your MCP server supports.&lt;/li>
&lt;li>&lt;strong>Allowed Registration Web Origins&lt;/strong>: include the web origin of the MCP Inspector backend.&lt;/li>
&lt;li>&lt;strong>Trusted Hosts&lt;/strong>: include the host or IP of the machine sending the dynamic registration request, that is, where your browser runs.&lt;/li>
&lt;/ul>
&lt;h3 id="for-mcp-2025-11-25-client-registration-with-client-id-metadata-document">For MCP 2025-11-25: client registration with Client ID Metadata Document&lt;/h3>
&lt;p>The latest version adds a new chapter: how clients register. It contemplates three approaches, and you choose according to your scenario:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Client ID Metadata Documents&lt;/strong>: when client and server do not know each other beforehand (the most common case).&lt;/li>
&lt;li>&lt;strong>Pre-registration&lt;/strong>: when a prior relationship already exists.&lt;/li>
&lt;li>&lt;strong>Dynamic Client Registration&lt;/strong>: for backward compatibility or specific requirements.&lt;/li>
&lt;/ul>
&lt;p>Keycloak supports the first, &lt;strong>OAuth Client ID Metadata Document (CIMD)&lt;/strong>, although it is worth knowing that it is an &lt;strong>experimental feature&lt;/strong>: it may bring breaking changes in future versions. To enable it, start Keycloak with the flag:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">bin/kc.sh start --features&lt;span class="o">=&lt;/span>cimd
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The idea behind CIMD is that the &lt;code>client_id&lt;/code> stops being an opaque identifier and becomes &lt;strong>a URL&lt;/strong> pointing at a document with the client&amp;rsquo;s metadata. Keycloak downloads that document and processes the request with what it finds there. For it to do so, you have to create a &lt;em>client policy profile&lt;/em> and a &lt;em>policy&lt;/em> that trigger it.&lt;/p>
&lt;p>&lt;strong>Profile.&lt;/strong> In &lt;em>Realm Settings → Client Policies → Profiles&lt;/em>, create a profile (e.g. &lt;code>cimd-profile&lt;/code>), add the &lt;code>client-id-metadata-document&lt;/code> executor and configure it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Allow http scheme&lt;/strong>: allows &lt;code>http&lt;/code> for the URLs (client_id, &lt;code>client_uri&lt;/code>, &lt;code>logo_uri&lt;/code>, &lt;code>jwks_uri&lt;/code>, etc.). Development only; in production &lt;strong>OFF&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Trusted domains&lt;/strong>: wildcard patterns of accepted domains (e.g. &lt;code>*.example.org&lt;/code>). If empty, all are denied.&lt;/li>
&lt;li>&lt;strong>Restrict same domain&lt;/strong>: if ON, it requires the &lt;code>client_id&lt;/code> URL, the &lt;code>redirect_uri&lt;/code> and the metadata URLs to all be under the same trusted domain.&lt;/li>
&lt;li>&lt;strong>Required properties&lt;/strong>: properties the metadata document must include.&lt;/li>
&lt;li>&lt;strong>Only Allow Confidential Client&lt;/strong>: if ON, only confidential clients are accepted (with &lt;code>jwks&lt;/code>/&lt;code>jwks_uri&lt;/code> and &lt;code>private_key_jwt&lt;/code> or &lt;code>tls_client_auth&lt;/code> authentication).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Policy.&lt;/strong> In &lt;em>Realm Settings → Client Policies → Policies&lt;/em>, create a policy (e.g. &lt;code>cimd-policy&lt;/code>), add the &lt;code>client-id-uri&lt;/code> condition and configure it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>URI scheme&lt;/strong>: schemes to recognise in the &lt;code>client_id&lt;/code> (in production, &lt;code>https&lt;/code> only).&lt;/li>
&lt;li>&lt;strong>Trusted domains&lt;/strong>: domains accepted in the &lt;code>client_id&lt;/code> host. If filled in, the condition is only true when the host matches; if left empty, it is always false.&lt;/li>
&lt;/ul>
&lt;p>Then associate the &lt;code>cimd-profile&lt;/code> with this policy. From then on, when a request arrives with a &lt;code>client_id&lt;/code> that is an &lt;code>https&lt;/code> URL from a trusted domain, Keycloak downloads the metadata document and uses it to process the request.&lt;/p>
&lt;p>&lt;strong>Global executor settings.&lt;/strong> Some parameters are not in the console and are passed as SPI options at startup:&lt;/p>
&lt;ul>
&lt;li>&lt;code>min-cache-time&lt;/code>: minimum document cache time (300 s by default).&lt;/li>
&lt;li>&lt;code>max-cache-time&lt;/code>: maximum cache time (259200 s by default, 3 days).&lt;/li>
&lt;li>&lt;code>upper-limit-metadata-bytes&lt;/code>: maximum document size (5000 bytes by default).&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">bin/kc.sh start &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --spi-client-policy-executor--client-id-metadata-document--min-cache-time&lt;span class="o">=&lt;/span>&lt;span class="m">600&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --spi-client-policy-executor--client-id-metadata-document--max-cache-time&lt;span class="o">=&lt;/span>&lt;span class="m">86400&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --spi-client-policy-executor--client-id-metadata-document--upper-limit-metadata-bytes&lt;span class="o">=&lt;/span>&lt;span class="m">10000&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="a-real-case-vs-code-desktop-as-an-mcp-client">A real case: VS Code desktop as an MCP client&lt;/h3>
&lt;p>Visual Studio Code is an MCP client that uses CIMD. When it connects to an MCP server that requires authorisation, it sends a &lt;code>client_id&lt;/code> that is an &lt;code>https&lt;/code> URL hosted on &lt;code>vscode.dev&lt;/code> (e.g. &lt;code>https://vscode.dev/mcp-client&lt;/code>), and Keycloak downloads its metadata from there. The detail to bear in mind is that VS Code is a &lt;strong>public client&lt;/strong> using &lt;strong>PKCE&lt;/strong> (no client secret) and, for the OAuth callback, it starts a local server with a &lt;code>redirect_uri&lt;/code> of the form &lt;code>http://127.0.0.1:&amp;lt;port&amp;gt;/callback&lt;/code>. Since that redirect is not in the &lt;code>vscode.dev&lt;/code> domain, you have to leave &lt;strong>Restrict same domain OFF&lt;/strong>.&lt;/p>
&lt;p>Start with &lt;code>--features=cimd&lt;/code> and configure:&lt;/p>
&lt;p>&lt;strong>Profile &lt;code>vscode-cimd-profile&lt;/code>&lt;/strong> (&lt;code>client-id-metadata-document&lt;/code> executor):&lt;/p>
&lt;ul>
&lt;li>Allow http scheme: &lt;strong>OFF&lt;/strong>&lt;/li>
&lt;li>Trusted domains: &lt;code>vscode.dev&lt;/code>, &lt;code>127.0.0.1&lt;/code>&lt;/li>
&lt;li>Restrict same domain: &lt;strong>OFF&lt;/strong> (the redirect is a localhost, not &lt;code>vscode.dev&lt;/code>)&lt;/li>
&lt;li>Only Allow Confidential Client: &lt;strong>OFF&lt;/strong> (VS Code is public)&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Policy &lt;code>vscode-cimd-policy&lt;/code>&lt;/strong> (&lt;code>client-id-uri&lt;/code> condition):&lt;/p>
&lt;ul>
&lt;li>URI scheme: &lt;code>https&lt;/code>&lt;/li>
&lt;li>Trusted domains: &lt;code>vscode.dev&lt;/code>&lt;/li>
&lt;li>Associate the &lt;code>vscode-cimd-profile&lt;/code> profile.&lt;/li>
&lt;/ul>
&lt;p>With this, when VS Code launches the request, Keycloak recognises the &lt;code>client_id&lt;/code> as a &lt;code>vscode.dev&lt;/code> URL, downloads the Client ID Metadata Document and completes the OAuth flow with the callback on localhost.&lt;/p>
&lt;h2 id="the-education-continues">The education continues&lt;/h2>
&lt;p>We have taught our MCP to identify itself and to ask permission in a standard way, reusing the identity Keycloak already governs in the rest of the house. That is no small thing: with a token properly tied to its audience, everything else starts to become possible.&lt;/p>
&lt;p>But, as with any child who grows up, this does not end here. The other two subjects we mentioned at the start are still pending, &lt;strong>separating the tenants (multitenancy)&lt;/strong> and &lt;strong>controlling what it spends (costs)&lt;/strong>, and Keycloak still has some maturing to do in places, such as native Resource Indicators support. We will cover them in future posts. For now, our MCP already knows how to say who it is. And in security, that is the first day of school.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a> — the layer that sits in front of these servers when there are several: permissions, cost, audit and the state of the specification in September 2026.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the piece from the inside and its position in the architecture, with the conformance status against MCP revision 2026-07-28.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — what this post&amp;rsquo;s setup is missing under revision 2026-07-28: protected resource metadata, audience validation and the end of dynamic registration.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Keycloak — Integrating with Model Context Protocol (MCP)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization">Model Context Protocol — Authorization&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.rfc-editor.org/rfc/rfc8707">RFC 8707 — Resource Indicators for OAuth 2.0&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.rfc-editor.org/rfc/rfc8414">RFC 8414 — OAuth 2.0 Authorization Server Metadata&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.rfc-editor.org/rfc/rfc7591">RFC 7591 — OAuth 2.0 Dynamic Client Registration Protocol&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>