ADR-0005: Issuer validation separated from key fetching, with additional issuers
- Status
- accepted
- Decided
- September 2026
- Deciders
- Platform Engineering
- Motivating failure
- A host move of the original hosted deployment: every authenticated API call returned 401 while browser logins appeared to work
Context
Keycloak pins its hostname (KC_HOSTNAME), so every token it issues
carries the public issuer, e.g.
iss = https://auth.<your-domain>/realms/agentic-ai (or
http://localhost:8180/realms/agentic-ai locally). Core, however, reaches
Keycloak on an internal name — compose DNS http://keycloak:8080 or an
in-cluster Service — never through the public edge.
Spring's default withIssuerLocation(issuerUri) auto-configuration
conflates two things: where to fetch the JWKS and which issuer to
validate tokens against. Point it at the internal host and every real token is
rejected; point it at the public hostname and core must hairpin through the public
edge, where a CDN or WAF may block non-browser clients.
When the hosted deployment moved hosts, core's issuer was set to an internal address with no additional issuer. Result: every authenticated API call returned 401 while browser logins appeared to work — the browser talks to Keycloak directly and never exercises core's issuer check.
Decision
- "Where JWKS comes from" (
KEYCLOAK_JWK_SET_URI, e.g.http://keycloak:8080/realms/agentic-ai/protocol/openid-connect/certs) is separated from "which issuer to validate". The auto-configured decoder that fetches JWKS fromissuer-uriis not used. - Core accepts a set of issuers:
KEYCLOAK_ISSUER_URIplus the comma-separatedKEYCLOAK_ADDITIONAL_ISSUERS(app.keycloak.additional-issuers), assembled inSecurityConfig. - Compose sets
KEYCLOAK_ISSUER_URIfromPUBLIC_AUTH_URL, the same value Keycloak uses forKC_HOSTNAME, so the two cannot drift. code-intel, app-builder and rl-trainer validate the same issuer. - Signature + issuer is not the whole check: an authorized-party
(
azp) allow-list is enforced too.
Alternatives considered
| Option | Why it lost |
|---|---|
| Single issuer, JWKS from the issuer URL | Forces core's key fetch through the public edge and makes every token validation depend on it. |
Unpin Keycloak's hostname (let iss follow the request URL) | Tokens would carry whichever hostname was hit, breaking validation consistency — contrary to Keycloak's own production guidance. |
Consequences
Positive
- Core validates real tokens without reaching the public edge; the same pattern works locally, with the Compose domain overlay and on Kubernetes.
- A domain change is survivable: list the old issuer in
KEYCLOAK_ADDITIONAL_ISSUERSwhile existing tokens expire.
Negative / costs
- Each extra accepted issuer widens the acceptance surface slightly; mitigated by the
azpcheck. Remove old issuers after a migration. - A mismatch produces a misleading symptom (logins work, APIs 401). That is why it is an ADR.
PUBLIC_AUTH_URL/realms/agentic-ai). Add any other issuer — such as the
old one during a domain change — to KEYCLOAK_ADDITIONAL_ISSUERS. Keep the
JWKS URL internal. Symptom to remember: browser logins look fine while every API call fails.
Evidence & references
core/security/.../SecurityConfig.java— issuer vs JWKS separation, additional issuers, theazpcheck.deploy/compose/docker-compose.yml,lite.yml,docker-compose.domain.yml—KEYCLOAK_ISSUER_URI,KEYCLOAK_JWK_SET_URI,KC_HOSTNAME.deploy/README.md— "Issuer consistency".