S15: upgrading does not hide custom attributes; *re-creating the realm* discards them
- Upgrade
- 23.0.7 → 24.0.5
- Scale
- small — 3 attribute-carrying users, plus SQL-inserted controls; 2 realms, later 3
- Database
- PostgreSQL 16.15, single node, container
- Topology
- single container
- Host
- Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
- Condition
- Unmanaged user attributes
Summary
S15's hypothesis was wrong, and the truth is worse.
The hypothesis: upgrading across 23 → 24, where the declarative user profile became the default, makes existing custom attributes invisible to the admin API — a data-visibility regression triggered purely by upgrading.
It does not. A realm created on 23.0.7 with users carrying department and
employeeId shows those attributes through the admin API on 23.0.7, on 24.0.5
and on 26.7.1, unchanged at every hop. An in-place upgrade preserves them,
because the migration sets that realm's unmanagedAttributePolicy to ENABLED.
What actually loses attributes is creating a realm on 24+. Such a realm has
no unmanagedAttributePolicy, and then the admin API:
- silently discards custom attributes on write —
HTTP 201, and zero rows reach the database; and - hides any that reach the database by another route — 2 rows present,
attributes: nullreturned.
So the danger is not the upgrade. It is a realm created on a new version.
⚠ CORRECTED 2026-08-26. This paragraph originally read "every path that produces a new realm on a new version: export/import migration, a rebuilt environment, disaster recovery, or simply a realm created after the upgrade." That generalisation is wrong. Run C exported a 23.0.7 realm and imported it into 24.0.5, 25.0.6, 26.0.0 and 26.7.1: all six
USER_ATTRIBUTErows survive, the admin API returns them, and the realm still accepts new ones — because the import setsunmanagedAttributePolicy=ENABLED, exactly as the in-place migration does. See2026-08-26-runC-realm-export-import-23-to-24.The path measured below — a realm created through the admin API — is the one that loses attributes. The measurements in this record stand; the inference from them to a class of untested paths did not.
Environment
| Field | Value |
|---|---|
| Keycloak path | 23.0.7 → 24.0.5 → 26.7.1 (in-place, real migrations) |
| Distribution / start mode | quay.io/keycloak/keycloak official image, start w/ external DB |
| Database | PostgreSQL 16.15, single node, container |
| Postgres tuning | stock — shared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0 |
| Adverse scenario | S15 |
| Dataset scale | small — 3 attribute-carrying users, plus SQL-inserted controls; 2 realms, later 3 |
| Seeding method | partialImport (--profile minimal), admin API user creation, and direct SQL inserts as a control |
| Topology | single container |
| Host | Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe |
| JVM heap | -Xms1g -Xmx4g |
Results
The upgrade path: no regression at any hop
Users created on 23.0.7 with attributes: {department, employeeId}:
USER_ATTRIBUTE rows | Admin API returns | |
|---|---|---|
| 23.0.7 (before) | 6 (2 per user × 3) | {"department":["engineering"],"employeeId":["E001"]} |
| 24.0.5 (after the hop that matters) | 6 | {"department":["engineering"],"employeeId":["E001"]} |
| 26.7.1 | 6 | {"employeeId":["E001"],"department":["engineering"]} |
Migration timings: 23.0.7 → 24.0.5 in 18s, 24.0.5 → 26.7.1 in 17s.
Two controls ruled out the other candidate explanations. Users inserted by
direct SQL — the way seed-sql.sh does it, with long_value_hash left NULL —
returned their attributes through the admin API on 26.7.1 just as
admin-API-created users did, both before and after a restart. The seeding
method is not the variable, and neither is the hash column.
The realm's birth version is the variable
On the same 26.7.1 server, side by side:
realm lab (created on 23.0.7, upgraded in place) | realm born26 (created on 26.7.1) | |
|---|---|---|
unmanagedAttributePolicy | ENABLED | UNSET |
| Create user with attributes | — | HTTP 201 |
USER_ATTRIBUTE rows written | 10 | 0 |
| Admin API returns | {"employeeId":…,"department":…} | attributes: null |
The user creation succeeded. It returned 201. The attributes were simply not persisted, and nothing said so.
The policy governs both sides, independently
Toggling unmanagedAttributePolicy on born26 and re-testing:
| Policy | Write through the admin API | Rows in DB after | Read back |
|---|---|---|---|
| UNSET | HTTP 201 — accepted | 0 | attributes: null |
ENABLED | HTTP 204 — accepted | 2 | {"employeeId":["E001"],"department":["engineering"]} |
| back to UNSET, rows left in place | — | 2 | attributes: null |
The last row is the S15 scenario as originally described: the data is present and invisible. The middle row shows the fix. And the first row shows the part nobody warns about — with the policy unset, the write is a no-op that reports success.
Setting the policy to ENABLED does not recover attributes that were
discarded earlier. There is nothing to recover.
Verification
| Claim | Primary source | Checked |
|---|---|---|
| Attributes survive 23.0.7 → 24.0.5 | admin API returns them on both sides; 6 rows in DB throughout | ☑ |
| And survive on to 26.7.1 | same | ☑ |
An upgraded realm has unmanagedAttributePolicy=ENABLED | /admin/realms/lab/users/profile | ☑ |
| A realm created on 26.7.1 has it unset | /admin/realms/born26/users/profile | ☑ |
| With it unset, an admin API write silently discards | HTTP 201, select count(*) from user_attribute = 0 | ☑ |
| With it unset, existing rows are hidden | 2 rows present, attributes: null | ☑ |
With it ENABLED, both write and read work | HTTP 204, 2 rows, attributes returned | ☑ |
| Seeding method is not the cause | SQL-inserted users returned attributes on 26.7.1, hash column NULL and populated alike | ☑ |
Keycloak's migration is what sets ENABLED | inferred from the two realms' policies, not read from source or release notes | ☐ |
| Behaviour of realm export/import across the boundary | not tested — the most likely real-world path to this, and the obvious next run | ☐ |
| Whether tokens/mappers see unmanaged attributes | not tested — this record covers the admin API only | ☐ |
ADMIN_VIEW / ADMIN_EDIT policy values | not tested — only UNSET and ENABLED | ☐ |
Publishable extract
Upgrading Keycloak does not hide your custom user attributes. Rebuilding the realm deletes them, and tells you it worked.
Keycloak 24 made the declarative user profile the default, and the widely repeated warning is that custom attributes go invisible when you cross that boundary. We tested it: a realm created on 23.0.7 with users carrying
departmentandemployeeId, upgraded in place to 24.0.5 and on to 26.7.1. The attributes were returned by the admin API at every hop, unchanged. The in-place upgrade sets that realm'sunmanagedAttributePolicytoENABLED, and everything keeps working.Then we created a new realm on 26.7.1 and posted the identical user:
POST /admin/realms/born26/users → HTTP 201 select count(*) from user_attribute where user_id = ... → 0 GET /admin/realms/born26/users?username=... → "attributes": nullThe request succeeded. The attributes were never written. Nothing warned us. A realm created on 24 or later has no
unmanagedAttributePolicy, and with it unset the admin API accepts custom attributes and drops them on the floor.The same policy hides rows that got there another way. We inserted attributes straight into Postgres, confirmed two rows, and the admin API still answered
attributes: null. Turning the policy on makes both problems go away — but it does not bring back what was discarded, because that was never stored.So the risk is not the upgrade. It is every path that produces a new realm on a new version: export-and-reimport migrations, environment rebuilds, disaster recovery, and any realm created after the upgrade. Those are precisely the paths people take because they consider them safer than an in-place upgrade.
One query tells you which kind of realm you have:
GET /admin/realms/{realm}/users/profile → .unmanagedAttributePolicyIf it is absent, your custom attributes are not being stored.
We nearly published the opposite of this. Our own large test realms were built on 26.0.0 and showed exactly the
attributes: nullsymptom the folklore predicts — and we had written it down as an upgrade regression before checking where the realm came from.