S7: the upgrade succeeds, the extension stops working, and nothing fails
- Scale
- 1,002 users, 2 realms
- Database
- PostgreSQL 16.15, single node, container
- Topology
- single container
- Host
- Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
- Condition
- Custom SPI vs old internals
Summary
Two findings, and the second is the sellable one.
1. "Has a custom SPI" is not a risk by itself. A provider that uses only stable SPI, compiled against 21.1.2, loads and works correctly on 26.7.1 — five majors and 619 removed public signatures later — with no source change at all. The same source compiles unchanged against 21.1.2, 25.0.6, 26.0.0 and 26.7.1.
2. A provider that touches a removed internal API does not fail the upgrade.
It fails silently, afterwards. The server starts clean in 15 seconds. The
provider loads, prints its init line, and appears in /admin/serverinfo. Every
check an operator runs during the window passes. Then the first event arrives
and the listener throws NoSuchMethodError — logged, dropped, and invisible to
the request that caused it, which returns 201.
For an event listener forwarding to a SIEM, that is audit forwarding that stops without an outage.
Environment
| Field | Value |
|---|---|
| Keycloak path | 25.0.6 → 26.0.0 → 26.7.1 (each hop a real migration, jar mounted throughout) |
| Distribution / start mode | quay.io/keycloak/keycloak official image, start w/ external DB, auto-build on start |
| Database | PostgreSQL 16.15, single node, container |
| Postgres tuning | stock — shared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0 |
| Adverse scenario | S7 |
| Dataset scale | 1,002 users, 2 realms |
| Seeding method | partialImport (bin/seed-realm.sh --profile typical) |
| Fixture profile | typical, plus custom provider jars in /opt/keycloak/providers |
| Topology | single container |
| Host | Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe |
| JVM heap | -Xms1g -Xmx4g |
| Lab change | fixtures/spi-brittle/ added; bin/build-spi.sh gained --module and now reads the artifactId from the pom |
Results
Compile-time
| Source | 21.1.2 | 25.0.6 | 26.0.0 | 26.7.1 |
|---|---|---|---|---|
| tame | builds | builds | builds | builds |
| brittle | — | builds | — | FAILS |
[ERROR] LabBrittleEventListener.java:[31,44] no suitable method found for
runJobInTransactionWithResult(org.keycloak.models.KeycloakSessionFactory,
org.keycloak.models.KeycloakContext,(s)->event[...]mId())
The API diff predicted the removal; the compiler confirms it independently.
Run-time
| Jar built against | Running on | Loads | In serverinfo | Events delivered |
|---|---|---|---|---|
| tame / 25.0.6 | 25.0.6 | ☑ | ☑ | ☑ user + admin |
| tame / 25.0.6 | 26.0.0 | ☑ | ☑ | ☑ user + admin |
| tame / 25.0.6 | 26.7.1 | ☑ | ☑ | ☑ user + admin |
| tame / 21.1.2 | 26.7.1 | ☑ | ☑ | ☑ user + admin |
| tame / 26.7.1 | 26.7.1 | ☑ | ☑ | ☑ |
| brittle / 25.0.6 | 26.7.1 | ☑ | ☑ | ☒ every event throws |
Migration timings with the jar mounted throughout: 25.0.6 → 26.0.0 in 13s, 26.0.0 → 26.7.1 in 16s. The provider did not slow, block or fail any hop.
What the brittle failure actually looks like
Startup — indistinguishable from success:
[lab-brittle] factory init -- provider loaded
/admin/serverinfo lists lab-brittle-listener among the event listeners.
wait-ready.sh returns 0 in 15 seconds. Container running.
First event — and only then:
ERROR [org.keycloak.services] KC-SERVICES0085: Failed to send type to
lab.LabBrittleEventListener@6012fda7: java.lang.NoSuchMethodError:
'java.lang.Object org.keycloak.models.utils.KeycloakModelUtils.runJobInTransactionWithResult(
org.keycloak.models.KeycloakSessionFactory, org.keycloak.models.KeycloakContext,
org.keycloak.models.KeycloakSessionTaskWithResult)'
And the blast radius is nil, which is the problem:
| Check after the failure | Result |
|---|---|
| Admin request that raised the event | HTTP 201 |
| Group actually created in the database | yes |
| Token endpoint | HTTP 200 |
| Container | running |
Keycloak's own admin_event_entity | 1,015 rows — still recording |
Keycloak's own event_entity | 8 rows — still recording |
| The tame listener on the same server | still delivering |
Keycloak's internal audit tables stay complete. Anyone verifying the audit trail by querying Keycloak sees nothing wrong. It is only the delivery to the custom listener — the path to whatever is downstream — that has stopped.
A note on KC-SERVICES0047
On both 26.0.0 and 26.7.1, every provider load emitted:
WARN [org.keycloak.services] KC-SERVICES0047: lab-event-listener
(lab.LabEventListenerProviderFactory) is implementing the internal SPI
eventsListener. This SPI is internal and may change without notice
eventsListener — the most commonly extended SPI in the wild — is classified
internal. Whether this warning also appears on 25.0.6 was not checked at the
time and the containers were gone; re-run on 2026-08-26
(2026-08-26-kc-services0047-not-new-in-26) shows it is present on
25.0.6 — the warning is not new in 26.
An unrelated observation, deliberately not attributed
A failed password grant against the lab realm returned 401 on 25.0.6 and
26.0.0, and 400 on 26.7.1. We tested whether the brittle listener caused it:
with the listener registered, removed, and registered again, the response was
400 every time. It is not the listener. Narrowed on 2026-08-26
(2026-08-26-invalid-grant-401-to-400-narrowed): it is a version change
that landed in 26.6.0 — 26.0.0 through 26.5.7 return 401, 26.6.0 onward
returns 400.
Verification
| Claim | Primary source | Checked |
|---|---|---|
| A 21.1.2-built provider works on 26.7.1 | built, mounted, both callbacks fired, registered in serverinfo | ☑ |
| The tame source compiles unchanged on all four versions | four successful build-spi.sh runs | ☑ |
| The removed signature is genuinely removed | spi-api-diff.sh 25.0.6 26.0.0 and an independent compile failure on 26.7.1 | ☑ |
| The brittle provider loads and registers on 26.7.1 | init line in log, present in /admin/serverinfo | ☑ |
| It throws on every event | KC-SERVICES0085 + NoSuchMethodError, both user and admin events | ☑ |
| The failure does not affect the request or the server | 201 / 200 / container running / group created | ☑ |
| Keycloak's own event tables stay populated | admin_event_entity 1,015, event_entity 8, read from Postgres | ☑ |
KC-SERVICES0047 is new in 26 | no — present on 25.0.6 (2026-08-26-kc-services0047-not-new-in-26) | ☑ |
| The 401 → 400 change is version-caused | yes — landed in 26.6.0 (2026-08-26-invalid-grant-401-to-400-narrowed) | ☑ |
| Behaviour of provider types other than event listeners | not tested — authenticators, user storage and mappers have different lifecycles and may fail at load rather than at use | ☐ |
Behaviour when the removed API is called from init() | not tested — would likely fail at startup, which is the safe case | ☐ |
Publishable extract
We upgraded Keycloak across five major versions with a custom extension installed. The upgrade succeeded. The extension stopped working. Nothing failed.
We built two event listeners. One uses only stable SPI. The other calls
KeycloakModelUtils.runJobInTransactionWithResult(...)— a helper any listener wanting its own transaction would reach for, and one that our API diff shows present in 25.0.6 and gone in 26.0.0.The stable one is the good news, and it is worth saying plainly: compiled against 21.1.2, it runs correctly on 26.7.1. Five majors, 619 removed public signatures in between, and the same source compiles unchanged against every version we tried. "The customer has a custom SPI" is not, on its own, a reason to expect trouble.
The other one is why you test. On Keycloak 26.7.1, carrying the jar built for 25.0.6:
- the server started in 15 seconds;
- the provider loaded and printed its startup line;
- it appeared in
/admin/serverinfoalongside the built-in listeners;- the readiness check passed.
Every check an operator runs during a maintenance window passed. Then the first event arrived:
ERROR KC-SERVICES0085: Failed to send type to lab.LabBrittleEventListener: java.lang.NoSuchMethodError: KeycloakModelUtils.runJobInTransactionWithResult(...)The admin request that triggered it returned 201. The group was created. The token endpoint returned 200. The container stayed up. And Keycloak's own
admin_event_entitytable kept recording normally — 1,015 rows — so anyone auditing Keycloak's audit trail sees a complete one.If that listener was shipping your authentication events to a SIEM, the events stopped and your dashboard says everything is fine.
The check that catches this is not a smoke test. It is: fire one real event after the upgrade and grep the log for
KC-SERVICES0085.