Keycloak 26.7.1, in a realm where every hand-authored client carries "pkce.code.challenge.method": "S256" and refuses plain. Enable the OAuth Client ID Metadata Document feature, point an authorization request at a published metadata document as its client_id, and Keycloak fetches that document and provisions a client from it. The client it provisioned accepted plain. It also accepted a request carrying no code_challenge at all.
Nothing was misconfigured. The realm's PKCE settings were what they had always been, the feature was enabled the way its guide describes, and the client policy gating the feature was doing exactly its job. There was no configuration mistake to find, which is the part worth writing down.
Why it has no pin
The pin is a client attribute. Every authored client in the realm names it in its own JSON, and that attribute is what makes those clients refuse plain. A client the executor creates carries only the attributes the executor puts there, and the provider that builds it sets three: consent required, display on the consent screen, and the consent text naming the document's host. No PKCE key is among them, and the CIMD provider source contains no reference to PKCE anywhere.
The pin was not the only thing missing, which is how you can tell this is inheritance rather than a PKCE-specific bug: the same client arrived with no basic scope and therefore no sub claim, no audience mapper, and none of the realm's capability scopes, because a provisioned client inherits the realm's default client scopes and this realm — every client of which had always named its own — had never declared any.
The feature itself is a draft standard for a client that identifies itself by URL instead of by pre-registration, and it is on here because that is how an MCP client registers with an authorization server it has never met. Keycloak documents the setup in its Model Context Protocol guide.
The warning you were already given
Before going further: Keycloak documents the mechanism behind all of this, and warns about it. The Server Administration Guide's Conditions evaluation section says that “some condition types are evaluated in all the request types, however some condition types are evaluated just during certain requests”, that “in some cases, it may not be obvious whether particular condition is evaluated or not”, and that you should therefore test your scenarios.
That warning lives in the guide covering client policies. The guide that walks you through turning this feature on is a different document and never sends you there: its source carries none of it at 26.7.1 or at 26.7.3, and the page as published on 2 September 2026 carries none of it either. What follows is what testing found, and the shape is sharper than it may not be obvious suggests.
The obvious repair
The feature is configured as a client profile behind a client policy. The profile holds client-id-metadata-document, the executor that fetches the document and provisions the client. The policy's condition, client-id-uri, decides which client_id URLs reach that executor.
So the repair writes itself. Keycloak ships a pkce-enforcer executor whose entire job is to require S256. Add it to the profile that is already provisioning the client you want pinned, and the client gets a pin. One executor, one line, done.
Why it is inert
ClientIdUriSchemeCondition has exactly one case in its switch statement, PRE_AUTHORIZATION_REQUEST, and that is a defensible choice — it is the only point in the flow where a client_id that happens to be a URL means anything, before any client has been resolved. Every other event falls through to default and returns ABSTAIN.
PKCEEnforcerExecutor acts on four events. On REGISTER and UPDATE it auto-configures and validates the pin on a proposed client; on AUTHORIZATION_REQUEST and TOKEN_REQUEST it checks the live request. PRE_AUTHORIZATION_REQUEST is not one of them.
The two sets are disjoint. On the one event the condition votes on, the executor does nothing. On the four events the executor works on, the condition abstains — and for a policy carrying a single condition, as this one does, an abstention is the entire vote, and the policy needs at least one yes to be satisfied. The scope of that last sentence is single-condition policies rather than these two in particular: a policy with several conditions can still be satisfied by a different one voting yes.
Nothing reports any of this. The profile is valid and the policy is valid, both import without complaint, and the admin console shows the executor sitting inside the profile where you put it. There is no error at startup and no line in the event log at request time, because from the engine's point of view nothing went wrong: a policy was not satisfied, which is the ordinary case for most policies on most requests. The executor sits in the profile looking like enforcement and enforcing nothing, and it survives review for precisely that reason.
Why adding a second condition does not rescue it
The next move is to keep the profile and widen the policy — add a second condition that does vote on the events the executor works on. That does not work either, and the reason is in the types rather than in a list of events.
A condition receives a ClientPolicyContext and decides from what that context carries. client-access-type votes in two situations and abstains in every other: where the context carries a resolved client it can read the access type off, and at REGISTER, where there is no client yet but there is a proposed representation to read instead. PRE_AUTHORIZATION_REQUEST offers neither. Nothing has been resolved — that is the entire purpose of the event, since the client_id is a URL nobody has dereferenced yet and dereferencing it is what the profile is about to do — and it is not REGISTER either.
So client-access-type abstains on PRE_AUTHORIZATION_REQUEST, the one event client-id-uri votes on. The condition that selects a client by the identifier it arrived with, and the condition that selects it by what it turned out to be, cannot both vote on any single event. The two things you want to say about a provisioned client cannot be said in one policy.
What the fix actually is
The pin is a second policy: pkce-enforcer in its own profile, bound by a condition on client-access-type being public — bound, that is, to something true of every client in this realm rather than to a name or to an identifier scheme.
That condition votes wherever a client is resolved, which covers both runtime events the executor handles, and it votes on REGISTER as well. The last one earns its place: provisioning fires a REGISTER client-policy event before the client is created, so the executor's auto-configure stamps S256 onto the provisioned client as it is being made. The attribute and the policy end up agreeing rather than one standing in for the other. It is three lines in the realm file, and the pin then holds for the authored clients, for the provisioned one that is not in that file at all, and for any client the realm gains later.
What to check in your own realm
The check is mechanical. For every executor in a profile, name the events it acts on. For every condition on the policy binding that profile, name the events it votes on. If the two sets are disjoint, the executor is decoration, and nothing anywhere is going to tell you.
The same silence has a second exit in this feature. Two allow-lists have to name a domain — the executor's permitted domains and the policy condition's — and naming it in one but not the other means the condition never matches, so the profile never runs, so the identifier is never dereferenced. The authorization request then fails with client_not_found: a no-such-client answer for a client nothing ever went to look for. A condition declining to match reads, downstream, as absence.
All of it was found by running the thing rather than by reading it, in a realm that exists to be read — keycloak/README.md carries the findings against the realm file they came out of, checked against 26.7.1 through a running container.
Top comments (0)