invalid_grant status change 401 → 400 — narrowed to 26.6.0
- Scale
- master realm only (auto-created), no seeded data
- Database
- Postgres 16, single node, fresh empty DB per version (docker compose down -v)
- Topology
- single container
- Host
- Hetzner ccx33 (8 vCPU / 32 GB), fsn1
- Rehearsal
- attempt 1 of 1 (diagnostic sweep, not a migration) — this lab holds a path to three clean runs, one exercising rollback
Environment
| Field | Value |
|---|---|
| Keycloak versions | 26.0.0, 26.1.0, 26.2.0, 26.3.0, 26.4.0, 26.5.0, 26.5.7, 26.6.0, 26.6.4, 26.7.0, 26.7.1 |
| Distribution | quay.io/keycloak/keycloak (official image) |
| Start mode | start w/ external DB |
| Database | Postgres 16, single node, fresh empty DB per version (docker compose down -v) |
| Postgres tuning | stock (compose db service sets none) |
| Adverse scenario | none — holds environment stock, varies the version |
| Dataset scale | master realm only (auto-created), no seeded data |
| Topology | single container |
| Host | Hetzner ccx33 (8 vCPU / 32 GB), fsn1 |
| JVM heap | container default |
| Probe | one password grant with a wrong password against admin-cli |
Procedure
For each version, in order: purge volumes, set KC_VERSION, start db + keycloak,
wait-ready.sh, then:
curl -s -w '\n%{http_code}' \
-d "client_id=admin-cli" -d "username=admin" \
-d "password=definitely-wrong" -d "grant_type=password" \
"http://localhost:8080/realms/master/protocol/openid-connect/token"
A fresh DB per version guarantees a clean master realm, ruling out the
"accumulated brute-force state" hypothesis left open in
2026-08-25-s7-custom-spi. Script: bin/narrow-invalid-grant.sh (one-off).
Result
| Version | HTTP status | Body |
|---|---|---|
| 26.0.0 | 401 | {"error":"invalid_grant","error_description":"Invalid user credentials"} |
| 26.1.0 | 401 | same |
| 26.2.0 | 401 | same |
| 26.3.0 | 401 | same |
| 26.4.0 | 401 | same |
| 26.5.0 | 401 | same |
| 26.5.7 (last 26.5 patch) | 401 | same |
| 26.6.0 | 400 | same |
| 26.6.4 | 400 | same |
| 26.7.0 | 400 | same |
| 26.7.1 | 400 | same |
The change landed in 26.6.0. The entire 26.5 line, including its final patch 26.5.7, returns 401; 26.6.0 onward returns 400. Body text is unchanged.
Verification
| Claim | Primary source | Checked |
|---|---|---|
| Change is version-caused, not realm state | fresh DB per version, clean master realm | ☑ |
| Boundary is 26.6.0, not a 26.5.x patch | 26.5.7 (last 26.5 patch) = 401, 26.6.0 = 400 | ☑ |
| Entire 26.6+ line is 400 | 26.6.0, 26.6.4, 26.7.0, 26.7.1 all 400 | ☑ |
RFC 6749 says 400 for invalid_grant | RFC 6749 §5.2 (as noted in s9-ldap-federation.md) | ☑ |
| Whether the change is in Keycloak release notes | checked 2026-08-26 — issue #45812 "ROPC: invalid_grant Error Response not RFC Compliant" (area oidc) in the 26.6.0 release notes | ☑ |
Publishable extract
A failed password grant against a Keycloak token endpoint answers 401 on 26.0.0 through 26.5.7, and 400 from 26.6.0 onward — same request, same wrong password, identical
Invalid user credentialsbody. The change landed in 26.6.0. RFC 6749 specifies 400 forinvalid_grant, so the newer behaviour is the correct one; but any client keying on 401 to detect a bad password breaks on upgrade past 26.6.0, and that code is common.