The Keycloak Upgrade Ledger

Every upgrade we have rehearsed, with the environment stated and the clock running.

2026-08-26 · run 2026-08-26-provider-types-brittle-protocol-mapper

brittle protocol mapper: the last row of the brittle matrix, loud at use

Database
PostgreSQL 16, fresh empty DB
Host
local laptop Docker (not Hetzner); 1k-class, no scale component
Condition
Custom SPI vs old internals — untested row: *brittle (removed-API) protocol mapper — load vs use, silent vs loud*

Why this run existed

2026-08-26-provider-types-protocol-mapper-brittle closed three of the four brittle rows (listener = lazy + silent; authenticator = lazy + loud; user-storage = lazy + loud) and left one ☐: the protocol mapper. The stated blocker was that "token-transform methods moved out of the base ProtocolMapper interface in 26, so the removed helper has no natural home there."

That blocker is refuted. The base ProtocolMapper interface (keycloak-server-spi-private) has never carried a token-transform method — its only abstract methods across 21.1.2 → 26.7.1 are getProtocol / getDisplayCategory / getDisplayType (plus a couple of defaults that grow over time, validateConfig from the start and getEffectiveModel from 23.x). The work method lives on the OIDC marker interface org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper (transformAccessToken), which sits in keycloak-services, not keycloak-server-spi-private, and whose signature is stable across the 25 → 26 boundary. So there is a natural home; the previous note mistook "the work method is not in the artifact we build against" for "the work method moved in 26".

This run builds fixtures/spi-brittle-pm/ — a mapper that implements ProtocolMapper + OIDCAccessTokenMapper and calls the same removed three-argument runJobInTransactionWithResult overload the other brittle fixtures use, from inside transformAccessToken().

Environment

FieldValue
Keycloakbuilt against 25.0.6, loaded on 26.7.1 (crosses the 26.0.0 API-removal boundary)
Fixturefixtures/spi-brittle-pm/ (new) — lab-brittle-protocolmapper
Buildbin/build-spi.sh 25.0.6 --module spi-brittle-pm (Maven in Docker)
New dependencykeycloak-services (provided) — for OIDCAccessTokenMapper, the one artifact the other three brittle fixtures do not need
DatabasePostgreSQL 16, fresh empty DB
Hostlocal laptop Docker (not Hetzner); 1k-class, no scale component
JVM heap-Xms1g -Xmx4g (compose default)

The removed API is the same vehicle used across the whole brittle matrix: the three-argument overload KeycloakModelUtils.runJobInTransactionWithResult( KeycloakSessionFactory, KeycloakContext, KeycloakSessionTaskWithResult) — present in 25.0.6, absent in 26.0.0 (replaced by a four-argument overload), living in keycloak-server-spi-private (no compatibility promise).

Procedure

cd lab
# fixture is checked in; build the brittle mapper against the OLD major
./bin/build-spi.sh 25.0.6 --module spi-brittle-pm
./bin/lab-up.sh 26.7.1          # jar is bind-mounted into /opt/keycloak/providers

# verify it LOADED (no ERROR, internal-SPI warning, registered)
docker compose logs keycloak | grep -E 'lab-brittle-pm|KC-SERVICES0047'
#   KC-SERVICES0047: lab-brittle-protocolmapper ... internal SPI protocol-mapper ...
#   [lab-brittle-pm] protocol mapper init -- provider loaded
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/admin/serverinfo \
  | jq '.providers["protocol-mapper"]'          # internal:true, "lab-brittle-protocolmapper" present

# trigger token issuance (the mapper's work method) with a service account:
# realm pmtest, confidential client brittle-client w/ the mapper attached, serviceAccountsEnabled
curl -s -o /tmp/tok.out -w '%{http_code}' \
  -d client_id=brittle-client -d client_secret=brittle-secret -d grant_type=client_credentials \
  http://localhost:8080/realms/pmtest/protocol/openid-connect/token
#   -> HTTP 500, {"error":"unknown_error",...}

Attach the mapper by including it in the client's protocolMappers at create time ({"protocolMapper":"lab-brittle-protocolmapper","config":{}}), then enable serviceAccountsEnabled and issue a client-credentials grant. Any token grant exercises transformAccessToken; the client-credentials grant is the cleanest trigger because it does not involve the user required-actions machinery (see note under Outcome).

Findings

Finding 1 — the token-transform work method is in keycloak-services, not server-spi-private, and it is stable across 25 → 26 ⭐

The base ProtocolMapper interface carries no token-transform method at any of 21.1.2, 23.0.7, 24.0.5, 25.0.6, 26.0.0 or 26.7.1 — its abstract methods are getProtocol, getDisplayCategory, getDisplayType (with getPriority and validateConfig defaults from the start, getEffectiveModel from 23.x). So the earlier "moved out of the base interface in 26" note does not describe a real change.

The work method is OIDCAccessTokenMapper.transformAccessToken (and OIDCIDTokenMapper.transformIDToken), which live in keycloak-services:

public interface org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper {
  public abstract AccessToken transformAccessToken(AccessToken,
    ProtocolMapperModel, KeycloakSession, UserSessionModel, ClientSessionContext);
}

That signature is byte-for-byte identical in 25.0.6, 26.0.0 and 26.7.1. The practical consequence: a customer's OIDC mapper that extends AbstractOIDCProtocolMapper (also keycloak-services) or implements OIDCAccessTokenMapper has a stable work-method home to override — which is exactly where a removed-API call would live, and therefore the right place to ask the load-vs-use question.

Finding 2 — a brittle protocol mapper fails at use, loudly (HTTP 500) ⭐

fixtures/spi-brittle-pm calls the removed three-argument overload from inside transformAccessToken().

It loads. On 26.7.1 the server starts with zero ERRORs, the factory init() runs, KC-SERVICES0047 fires, and the provider registers in /admin/serverinfo under protocol-mapper (SPI internal: true):

WARN KC-SERVICES0047: lab-brittle-protocolmapper (lab.LabBrittleProtocolMapper)
  is implementing the internal SPI protocol-mapper. This SPI is internal and may
  change without notice
[lab-brittle-pm] protocol mapper init -- provider loaded

It dies at first use, loudly. A client-credentials grant returns HTTP 500 with the standard unknown_error body, and the log carries the uncaught error — thrown from transformAccessToken and propagating straight up through the token endpoint:

ERROR [org.keycloak.services.error.KeycloakErrorHandler] (executor-thread-1)
  Uncaught server error: java.lang.NoSuchMethodError: 'java.lang.Object
  org.keycloak.models.utils.KeycloakModelUtils.runJobInTransactionWithResult(
  org.keycloak.models.KeycloakSessionFactory, org.keycloak.models.KeycloakContext,
  org.keycloak.models.KeycloakSessionTaskWithResult)'
    at lab.LabBrittleProtocolMapper.transformAccessToken(LabBrittleProtocolMapper.java:89)
    at org.keycloak.protocol.oidc.TokenManager.transformAccessToken(TokenManager.java:827)
    at org.keycloak.protocol.oidc.TokenManager.createClientAccessToken(TokenManager.java:429)
    at org.keycloak.protocol.oidc.grants.ClientCredentialsGrantType.process(ClientCredentialsGrantType.java:147)
    at org.keycloak.protocol.oidc.endpoints.TokenEndpoint.processGrantRequest(TokenEndpoint.java:174)

Finding 3 — the brittle matrix is now complete, and it splits two ways ⭐

All four provider types now have a brittle row, and none fails at load:

TypeFails atVisibilityRecord
event listenerusesilent (request succeeds)S7
authenticatoruseloud (HTTP 500)…-provider-types-protocol-mapper-brittle.md F2
user-storageuseloud (HTTP 500)…-provider-types-protocol-mapper-brittle.md F4
protocol mapperuseloud (HTTP 500)this run, F2

Keycloak never invokes a provider's work method during startup, so a removed API that only the work method reaches never breaks the upgrade itself. The visibility split is: event dispatch swallows the error (listener); every other path propagates it to HTTP 500. A protocol mapper fails exactly the way an authenticator and a user-federation provider do, not the way a listener does.

Outcome

Operational note — password grant is the wrong trigger for a mapper

A password (ROPC) grant against a hand-rolled realm returned 400 invalid_grant: "Account is not fully set up" (error=resolve_required_actions) even with the user's requiredActions empty and emailVerified=true; clearing required actions and toggling realm flags did not move it. The client-credentials grant sidesteps this and exercises transformAccessToken directly, so it is the canonical trigger for a protocol-mapper fixture. The ROPC quirk is not diagnosed here and is not a finding of this run — it is the auth-session required-actions path, upstream of the mapper, and deserves its own look if the lab ever needs ROPC against a hand-rolled realm.

Verification

ClaimPrimary sourceChecked
Base ProtocolMapper has no token-transform method at any versionjavap of org.keycloak.protocol.ProtocolMapper in server-spi-private 21.1.2 / 23.0.7 / 24.0.5 / 25.0.6 / 26.0.0 / 26.7.1 — abstract methods only getProtocol/getDisplayCategory/getDisplayType throughout
OIDCAccessTokenMapper lives in keycloak-services, not server-spi-privatejar listing: class present in keycloak-services, absent from the other three artifacts
transformAccessToken signature stable 25 → 26javap of OIDCAccessTokenMapper in keycloak-services 25.0.6 / 26.0.0 / 26.7.1 — identical
Brittle mapper loads on 26.7.1 (not load-failing)startup log: init -- provider loaded, 0 ERRORs, KC-SERVICES0047, registered in serverinfo
Brittle mapper dies at use, loudlyHTTP 500 + unknown_error body + NoSuchMethodError stack quoted above
Throw site is the work methodstack: LabBrittleProtocolMapper.transformAccessTokenTokenManager.transformAccessTokencreateClientAccessTokenTokenEndpoint
The exact 24.x release that split model/storagestill not narrowed — bracketed to (23.0.7, 24.0.5]

Publishable extract

A custom protocol mapper that outlived its API breaks your next token, not your upgrade.

We built an OIDC protocol mapper against Keycloak 25.0.6 that calls a helper removed in 26.0.0, then loaded it on 26.7.1. The server started cleanly — the mapper registered, /admin/serverinfo listed it, zero errors. The first token request that reached its transformAccessToken method returned HTTP 500 and a full NoSuchMethodError stack as an uncaught server error.

That is the same shape as a broken authenticator and a broken user-federation provider, and the opposite of a broken event listener: event dispatch swallows the exception and the login still succeeds, while the mapper's failure propagates out of the token endpoint. So a "can users still get a token" check catches a broken mapper and misses a broken listener. The mapper's work method lives in Keycloak's keycloak-services artifact, not the SPI artifact — a mapper that registers cleanly can still be sitting on an API that no longer exists.

← Back to the Ledger