# Upgrading to rc.8 / rc.9

What you must change, and what will behave differently, when you move a HelixIAM install from `v1.0.0-rc.7` to `v1.0.0-rc.8` or `v1.0.0-rc.9`.

!!! info "Which release has what"
    Both are released: `v1.0.0-rc.8`, and `v1.0.0-rc.9` (2026-10-13), which moves the server to **Spring Boot 4.1** (Spring Framework 7, Spring Security 7.1) and adds the experimental EU Digital Identity Wallet sign-in and a complete outbound-request (SSRF / DNS-rebinding) hardening. Items marked *rc.9* arrived with rc.9. If you upgrade from rc.7 or rc.8 to rc.9, apply **every** item on this page — and read [item 13](#13-spring-boot-4-do-not-run-old-and-new-pods-side-by-side) first: the rc.9 rollout must not run old and new pods side by side. The sources are the BREAKING checklist at the top of `CHANGES-1.0.md` and `docs/UPGRADING.md` in the product repository.

## Before you upgrade: checklist

Work through this list before you roll out. Each item links to its detail below.

| # | Do this | Applies from |
| --- | --- | --- |
| 1 | Behind any TLS-terminating proxy, ingress, load balancer or mesh, set [`HELIX_TRUSTED_PROXIES`](#1-trusted-proxies-are-now-required-behind-a-proxy) | rc.8 |
| 2 | List every other host name clients use in [`HELIX_ALLOWED_HOSTS`](#2-the-server-answers-only-for-its-own-host-names) | rc.8 |
| 3 | Check clients that reach the server on [another port](#3-the-host-port-counts-too) than the base URL's (port mappings) | rc.9 |
| 4 | Make sure your proxy appends to [`X-Forwarded-For`](#4-x-real-ip-is-no-longer-read): `X-Real-IP` is ignored | rc.9 |
| 5 | If you are adding `DB_ENCRYPTION` to an install that ran without it, plan the [plaintext migration window](#5-plaintext-secret-rows-a-time-boxed-migration-window) | rc.8 (enforced at runtime from rc.9) |
| 6 | Read the first start's log for the [legacy re-encryption](#6-legacy-encrypted-secrets-are-re-encrypted-at-startup) WARN | rc.9 |
| 7 | Read the generated admin password from [the password file](#7-the-generated-admin-password-is-no-longer-logged), not the log | rc.9 |
| 8 | Check that each eID connection's [minimum level of assurance](#8-unknown-eid-minimum-levels-are-refused) is one the server knows | rc.8 |
| 9 | Classic DigiD now requests the [Logius context classes](#9-classic-digid-requests-the-logius-context-classes) | rc.9 |
| 10 | Stop reading [identity-provider secrets](#10-identity-provider-secrets-are-write-only-in-the-admin-api) back from the admin API (sync scripts, Terraform) | rc.9 |
| 11 | Expect [in-flight federated logins](#11-in-flight-federated-and-wallet-logins-fail-once-during-the-rollout) to fail once during the rollout | rc.9 |
| 12 | Give the server [a database or Redis](#12-the-wallet-transaction-store-needs-the-database-or-redis) (no in-memory fallback) | rc.9 |
| 13 | **Spring Boot 4:** rename `FACTOR_…` roles, back up, upgrade with [no old and new pods side by side](#13-spring-boot-4-do-not-run-old-and-new-pods-side-by-side) (Recreate or scale to 0); know the rollback | rc.9 |
| 14 | If you set Spring properties directly, [rename them](#14-renamed-spring-properties) | rc.9 |
| 15 | Allow an internal or loopback [SMTP relay](#15-smtp-relays-pass-the-egress-guard) (realm or global) in `HELIX_EGRESS_ALLOWED_PRIVATE_HOSTS`, and odd ports in `HELIX_MESSAGING_SMTP_ALLOWED_PORTS` | rc.9 |
| 16 | Allow an internal [LDAP directory](#16-ldap-directories-pass-the-egress-guard) in `HELIX_FEDERATION_LDAP_ALLOWED_HOSTS` | rc.9 |
| 17 | Rename [`helix.outbound.fetch.login.*`](#17-outbound-fetch-limits) if you tuned it | rc.9 |
| 18 | Give every `private_key_jwt` client an [`https` JWKS URL](#18-client-jwks-urls-must-be-https) | rc.9 |
| 19 | Use `/agent/delegation/token`, not [`actor_token` on `/oauth2/token`](#19-actor_token-is-refused-on-oauth2token) | rc.9 |
| 20 | Expect wallet issuer certificates to be checked with [`HARD_FAIL`](#20-wallet-issuer-revocation-defaults-to-hard_fail) by default | rc.9 |

---

## 1. Trusted proxies are now required behind a proxy

**What changed.** The server honours forwarded headers only from the proxies you list. `X-Forwarded-For` (the client IP) and `Forwarded` / `X-Forwarded-Proto` / `-Host` / `-Port` / `-Prefix` (the scheme, host and OIDC issuer) are applied only when the TCP peer is in `HELIX_TRUSTED_PROXIES`; from any other peer they are removed. Before rc.8 they were honoured from any client, so a single request could steer the issuer in the discovery document or claim any client IP for the rate limiter and the audit log.

**What you must do.** Behind an ingress controller, an external load balancer, a Gateway API route, a service mesh or the console's nginx, set the addresses the proxy connects **from**:

| Setting | Value |
| --- | --- |
| Environment | `HELIX_TRUSTED_PROXIES` (property `helix.security.trusted-proxies`) |
| Helm | `config.trustedProxies` |
| Format | comma-separated IP addresses or CIDR ranges, IPv4 or IPv6 |
| Default | empty: no proxy is trusted |

```bash
# Example: the ingress controller's pod CIDR
HELIX_TRUSTED_PROXIES=10.42.0.0/16
```

```bash
# Helm
helm upgrade helixiam deploy/helm/helixiam --reuse-values \
  --set 'config.trustedProxies={10.42.0.0/16}'
```

**If you skip it.** Behind a TLS-terminating proxy the server sees plain HTTP: the request-derived issuer and redirects become `http://`, so relying parties reject the tokens' `iss`. Every request also appears to come from the proxy, so all clients share one sign-in rate-limit bucket and the audit log records the proxy's address. The server logs an ERROR, at most once a minute, when an `https` `IDP_BASE_URL` is reached over plain HTTP, and the Helm install notes print a warning whenever `config.trustedProxies` is empty.

!!! warning "Never list addresses clients can connect from directly"
    A listed address is believed when it says who the client is. Only list your own proxies.

## 2. The server answers only for its own host names

**What changed.** Outside the `dev` profile, a request whose `Host` (after trusted forwarded-header processing) is not the host of `IDP_BASE_URL`, the host of `SP_BASE_URL`, or a host listed in `HELIX_ALLOWED_HOSTS` gets `400`, before any issuer or URL is derived from it. Before rc.8, a server reached directly answered `Host: evil.example` with the issuer `http://evil.example/realms/…`.

**What you must do.** List every *other* host name that clients use to reach the server, for example the in-cluster service name that relying parties call for tokens or JWKS:

| Setting | Value |
| --- | --- |
| Environment | `HELIX_ALLOWED_HOSTS` (property `helix.security.allowed-hosts`) |
| Helm | `config.allowedHosts` |
| Format | comma-separated; `name` accepts any port, `name:port` (or `[v6]:port`) only that port |
| Default | empty: only the hosts of `IDP_BASE_URL` and `SP_BASE_URL` |

```bash
HELIX_ALLOWED_HOSTS=helixiam.identity.svc,helixiam.identity.svc.cluster.local
```

```bash
# Helm
helm upgrade helixiam deploy/helm/helixiam --reuse-values \
  --set 'config.allowedHosts={helixiam.identity.svc}'
```

Probes keep working: the connection's own address (kubelet probes send the pod IP), `localhost` from a loopback peer (Docker `HEALTHCHECK`) and `/actuator/health/**` stay reachable.

## 3. The Host port counts too

*rc.9.* In rc.8 the host allow-list ignored the port. From rc.9, outside `dev`:

- a host of `IDP_BASE_URL` or `SP_BASE_URL` is accepted only on **that URL's port** (its explicit port, or 443/80 for https/http);
- a `Host` header without a port is accepted;
- a `HELIX_ALLOWED_HOSTS` entry accepts any port unless it is written `host:port` (or `[v6]:port`);
- a port outside 1–65535 gets `400`;
- the probe exemptions (the connection's own address, `localhost` from a loopback peer) apply only on the port the connection arrived on, or the scheme's default port; `/actuator/health/**` still answers on any host and port.

**What you must do.** A client that reaches the server by its base URL's host on **another** port, for example through a Docker port mapping, needs that `host:port` in `HELIX_ALLOWED_HOSTS`. Otherwise it gets `400`.

```bash
# IDP_BASE_URL=http://localhost:8080, but a tool calls http://localhost:18080
HELIX_ALLOWED_HOSTS=localhost:18080
```

## 4. `X-Real-IP` is no longer read

*rc.9.* The client IP comes only from `X-Forwarded-For`, and only from a trusted proxy (read from the right; the first address that is not a trusted proxy is the client). `X-Real-IP` is never read, not even from a trusted proxy: a trusted hop that neither set nor stripped it (an L4 load balancer, an ingress with forwarded headers off) let a client choose its own address for rate limits and the audit log.

**What you must do.** Make sure every trusted proxy **appends** to `X-Forwarded-For` (nginx `$proxy_add_x_forwarded_for`; ingress-nginx and cloud load balancers do). A proxy that sets only `X-Real-IP` now makes every client appear as the proxy.

## 5. Plaintext secret rows: a time-boxed migration window

**What changed (rc.8).** A secret column (TOTP and HOTP secrets, realm signing keys, client and webhook secrets, SCIM tokens, messaging-provider secrets, SAML relying-party certificates) whose stored value cannot be decrypted used to be returned as stored. It now fails the read. A deployment that ran without `DB_ENCRYPTION` and then sets it may hold plaintext rows; reading them is an explicit, loud and time-boxed migration mode:

| Variable | Default | Meaning |
| --- | --- | --- |
| `HELIX_DB_ENCRYPTION_MIGRATE_PLAINTEXT` | `false` | `true` reads undecryptable secret columns as stored (a WARN per read, naming entity and column), encrypts them on their next write, and logs an ERROR banner at startup and every hour |
| `HELIX_DB_ENCRYPTION_MIGRATE_UNTIL` | empty | the last day of the window, `yyyy-MM-dd` (UTC, inclusive); outside `dev` the server refuses to start in migration mode unless this is a date at most 30 days ahead |

*rc.9:* the end date is also enforced **while the server runs**. After that day, a plaintext read fails closed and logs an ERROR naming the date (rc.8 only checked it at startup).

```bash
DB_ENCRYPTION=<your 32-hex-character key>
HELIX_DB_ENCRYPTION_MIGRATE_PLAINTEXT=true
HELIX_DB_ENCRYPTION_MIGRATE_UNTIL=2026-11-10
```

Unset both once the rows are rewritten. `HELIX_ALLOW_PLAINTEXT_SECRETS` again only means "accept running without a key", and an empty `DB_ENCRYPTION` now means "no key" (it used to fail startup with "Empty key").

!!! danger "The mode also hides a changed key"
    While it is on, a value written under a *different* `DB_ENCRYPTION` key is read as stored too. That is why it is loud and time-boxed. Never use it to paper over a key change.

## 6. Legacy-encrypted secrets are re-encrypted at startup

*rc.9.* At every startup, values of every encrypted column still in the legacy AES/ECB format (realm keys, TOTP and HOTP secrets, client secrets, SCIM tokens, webhook secrets, identity-provider and messaging secrets, SAML relying-party certificates, verifier keys, wallet peppers) are re-encrypted to AES-GCM. The job is idempotent and logs table, column and row id only. Rows it cannot read are skipped and named in a production-readiness WARN.

After that run the legacy format is **no longer read**: a remaining legacy value fails closed, unless you set:

| Variable | Default | Meaning |
| --- | --- | --- |
| `HELIX_DB_ENCRYPTION_ALLOW_LEGACY` | `false` | `true` keeps reading the legacy format after the startup run |

Each column is migrated in its own transaction. A column that fails with a database error is logged at ERROR by table and column, reported as a readiness WARNING, and counted in the gauge `helix_db_encryption_legacy_failed_columns`; the legacy fallback then stays on until a later start migrates it.

**What you must do.** Usually nothing: the first start rewrites the legacy rows. Check that start's log for the readiness WARN. Only if the WARN names rows, or you attach a database restored from an older backup to a running server, set `HELIX_DB_ENCRYPTION_ALLOW_LEGACY=true` (or restart, which re-runs the job) while you investigate.

## 7. The generated admin password is no longer logged

*rc.9.* When `HELIX_ADMIN_PASSWORD` is unset, the first boot still generates a strong random password for the master-realm admin, but it is **never logged**. It is written with mode `0600` to `HELIX_ADMIN_PASSWORD_FILE`, and only the path is logged.

| Variable | Default |
| --- | --- |
| `HELIX_ADMIN_PASSWORD_FILE` | `$TMPDIR/helixiam-admin-password` (`/tmp/helixiam-admin-password` in the container image) |

Read it from where the server ran, then sign in and change it:

```bash
# Docker Compose (after removing HELIX_ADMIN_PASSWORD from the compose file)
docker compose exec server cat /tmp/helixiam-admin-password

# Helm: /tmp is an emptyDir, so read it before that pod restarts.
# With several replicas, exec into the pod whose log says it generated the password.
kubectl exec deploy/helixiam -- cat /tmp/helixiam-admin-password
```

`HELIX_ADMIN_USERNAME`, `HELIX_ADMIN_PASSWORD` and `HELIX_ADMIN_PASSWORD_FILE` are declared in `application.properties`, so they also work as command-line arguments (`--HELIX_ADMIN_PASSWORD=…`). For any non-local install, set `HELIX_ADMIN_PASSWORD` explicitly (Helm: the `admin-password` key of `secrets.existingSecret`).

## 8. Unknown eID minimum levels are refused

**What changed (rc.8).** An eID connection (DigiD, eHerkenning, eIDAS) whose `minimumLoa` was not a level the server knows accepted **every** asserted level. An unrecognised non-blank minimum is now never met (fail closed); a blank minimum still means "no minimum". The keywords the console stores are now mapped to the scheme's ladder, and eHerkenning EH2+ (`loa2plus`) ranks above EH2 and below EH3.

An unknown minimum is refused:

- when the connection is saved through the admin API: `400` with `fieldErrors.minimumLoa`;
- when a realm is imported: the import reports it as a failed import with the field error (*rc.9*; rc.8 saved it and skipped it with a WARN at load);
- when a stored connection is built: it is skipped with a warning (a connection in `helix.federation.eid` fails startup).

**What you must do.** Check every eID connection's minimum against the [accepted values](../federation/digid.md#levels-of-assurance). A connection with a value outside that list stops working after the upgrade.

## 9. Classic DigiD requests the Logius context classes

*rc.9.* The classic DigiD AuthnRequest used to ask for the `urn:nl-eid-gdi:1.0:LoA:*` URN, which a DigiD endpoint does not know. Following Logius' *Koppelvlakspecificatie DigiD SAML Authenticatie* v3.5, a DigiD keyword is now requested as the SAML 2.0 context class of that level, with `RequestedAuthnContext Comparison="minimum"`:

| DigiD level | Keywords | Requested class (`urn:oasis:names:tc:SAML:2.0:ac:classes:…`) |
| --- | --- | --- |
| Basis | `loa1`, `basis` | `PasswordProtectedTransport` |
| Midden | `loa2`, `midden` | `MobileTwoFactorContract` |
| Substantieel | `loa3`, `substantieel` | `Smartcard` |
| Hoog | `loa4`, `hoog` | `SmartcardPKI` |

The asserted class is ranked on the same ladder (rc.8 already ranked these classes when comparing). A minimum stored as an `urn:nl-eid-gdi:1.0:LoA:*` URN is still compared as stored but requested as the context class of the same level, and brokers that assert those URNs keep working. No eID scheme sends an nl-eid-gdi URN in the request any more.

**What you must do.** Nothing for the console keywords. If you worked around the old behaviour by storing the oasis class by hand as an "Other (custom)" minimum, that is no longer needed. A broker that *requires* the nl-eid-gdi URN in the AuthnRequest is not supported as a DigiD connection. See [DigiD](../federation/digid.md#levels-of-assurance).

## 10. Identity-provider secrets are write-only in the admin API

*rc.9 — API change.* `GET /admin/realms/{realm}/identity-providers` (list and by alias) no longer returns secret values: the OIDC `clientSecret`, the eID SP private keys (`spDecryptionPrivateKey`, `spSigningPrivateKey`), the LDAP bind password, and the wallet's keys. Each one comes back as a flag instead:

```json
{ "clientSecretSet": true }
```

On a write (`POST` or `PUT`):

| You send | Effect |
| --- | --- |
| the key absent, `null` or blank | the stored secret is kept |
| a value | the stored secret is replaced |
| `"clear<Key>": "true"` (e.g. `"clearClientSecret": "true"`) and no value | the stored secret is removed |
| a value **and** its clear flag | `400` |

Secrets are now encrypted at rest (Flyway `V75` / `V75.1`), and realm exports mask them.

**What you must do.** Any script or tool that reads a secret back must stop doing so. A tool that compares the returned secret with its desired value (a sync script, a Terraform provider) will always see a difference. The bundled Terraform provider is not affected. At rc.9 it had no identity-provider resource; its `helix_wallet_identity_provider` resource, added after rc.10, never reads a secret back: `access_certificate_private_key` keeps the value in state, and removing it from the configuration clears it on the server. A third-party or home-grown provider that manages identity providers is affected.

## 11. In-flight federated and wallet logins fail once during the rollout

*rc.9.* The broker's per-login session state (state, nonce, SAML request id, browser binding, wallet transaction) is now keyed by realm **and** alias, so logins in two realms that use the same alias no longer clobber each other. The session keys were renamed as a result.

**What to expect.** A federated (OIDC, SAML, eID) or wallet login, or a wallet link in the account console, that **starts on the old version and finishes on the new one** ends with `error=federation` (or a refused link). Starting it again works. This is transient and affects no data. Roll out outside peak hours if that matters to you.

## 12. The wallet transaction store needs the database or Redis

*rc.9.* The [EU Digital Identity Wallet](../federation/eudi-wallet.md) keeps each login's in-flight state in a transaction store shared by all replicas: PostgreSQL (`wallet_transaction`, Flyway `V79`) or Redis, chosen from your session and token stores. The in-memory store is used **only** when you ask for it with `HELIX_WALLET_TRANSACTION_STORE=memory` (development and tests, one replica). A server with neither Redis nor a database that was not asked for `memory` **refuses to start** instead of falling back silently.

**What you must do.** Nothing for a normal install, which always has PostgreSQL. See [Transaction store](../federation/eudi-wallet.md#transaction-store-and-multiple-replicas).

## 13. Spring Boot 4: do not run old and new pods side by side

*rc.9.* The server moved from Spring Boot 3.5 (rc.8) to Spring Boot 4.1 (Spring Security 7.1). It reads everything the earlier release stored — sessions, authorization codes, refresh tokens, device codes, consents — in PostgreSQL and in Redis. **The reverse does not hold**: the earlier release cannot read what rc.9 writes or rewrites. Every new sign-in carries Spring Security 7's sign-in factor (`FactorGrantedAuthority`), a class the earlier release does not have.

| Written by rc.9 | Read by an earlier pod, PostgreSQL store (`queue`) | Read by an earlier pod, Redis store |
| --- | --- | --- |
| a browser session (any sign-in) | dropped (`HTTP session dropped` WARN): the user is signed out | **HTTP 500** on every request with that cookie, until the session expires or is deleted |
| an OAuth2 authorization rc.9 created **or rewrote** (sign-in, code exchange, refresh, revocation, consent, device-flow step) | treated as absent: refresh fails (`invalid_grant`), introspection says `active: false` | **HTTP 500** for that token until it expires or is deleted |

Access tokens are JWTs and stay valid for their lifetime on either version. The stores: PostgreSQL tables `helix_authorization`, `helix_authorization_token` and `helix_http_session` (`HELIX_SESSION_STORE=queue`); Redis keys `spring:session:*` (sessions, the default store) and `helix:authz:*` (tokens with `HELIX_TOKEN_STORE=redis`).

**1. Rename roles named `FACTOR_…`.** The prefix (any case) is now reserved for Spring Security's sign-in factors (`FACTOR_PASSWORD` marks every password login). A role with such a name is ignored — it grants nothing in admin RBAC and never appears in a token — and the admin API and realm import refuse to create one (`400`). List them; an empty result means nothing to do:

Run this query with `psql "$DATABASE_URL"`:

```sql
SELECT 'realm role' AS kind, tenant_id AS realm, name AS role, NULL AS holder
  FROM user_roles WHERE name ILIKE 'factor\_%'
UNION ALL
SELECT 'client role', realm_id, name, client_id
  FROM client_role WHERE name ILIKE 'factor\_%'
UNION ALL
SELECT 'service-account role', realm_id, role_name, client_id
  FROM client_service_account_role WHERE role_name ILIKE 'factor\_%'
UNION ALL
SELECT 'agent role', realm_id, roles, name
  FROM agent_identity WHERE roles ~* '(^|[,[:space:]/])factor_'
ORDER BY 1, 2, 3;
```

It covers realm, client, service-account and agent roles. Rename any it lists (create the new role, move its users, grants and mappings, delete the old one) before upgrading.

**2. Back up** PostgreSQL, the token store included (a `pg_dump` of the whole database covers it), and Redis if you use it (`redis-cli BGSAVE`, then copy `dump.rdb`).

**3. Upgrade without mixing versions.** A rolling update, or scaling to one replica, still runs old and new side by side. Pick one:

- **Helm, Recreate** — every old pod stops before a new one starts (the server is unavailable for one start-up):

  ```bash
  helm upgrade helixiam deploy/helm/helixiam --reuse-values --server-side=false \
    --set image.tag=<new version> --set strategy.type=Recreate
  ```

  `--server-side=false` is needed **once, with Helm 4**: the rc.8 chart never rendered `spec.strategy`, so under Helm 4's default server-side apply the `rollingUpdate` block Kubernetes defaulted would survive and the API server would reject the Deployment. With Helm 3 leave the flag out. From rc.9 the chart renders the strategy in full (the new value `strategy`), so later changes need no flag.
- **Or scale to zero first**, then upgrade (the upgrade restores `replicaCount`):

  ```bash
  kubectl -n <namespace> scale deploy/helixiam --replicas=0
  kubectl -n <namespace> wait --for=delete pod -l app.kubernetes.io/name=helixiam --timeout=180s
  helm upgrade helixiam deploy/helm/helixiam --reuse-values --set image.tag=<new version>
  ```
- **Docker Compose / plain containers:** stop every old container, then start the new ones.

**4. Check** readiness (`/actuator/health/readiness`), one sign-in, and one refresh of a token issued before the upgrade. Later releases can go back to rolling updates: `--set strategy.type=RollingUpdate`.

**Rolling back to rc.8** signs users out and drops every authorization rc.9 wrote or rewrote. The rc.8 chart has no `strategy` value, so `helm rollback` alone would mix the versions. Instead:

1. Scale to zero and wait until every rc.9 pod is gone (the two `kubectl` commands above).
2. Delete what rc.8 cannot read from **Redis** (PostgreSQL stores: nothing to delete): sessions `spring:session:*`, and with `HELIX_TOKEN_STORE=redis` tokens `helix:authz:*` (every client then needs a new sign-in or `client_credentials` token). Otherwise rc.8 answers HTTP 500 for them until they expire. The script uses SCAN and UNLINK:

   ```bash
   # REDIS_HOST, REDIS_PORT; REDIS_PASSWORD (requirepass) or REDIS_USER + REDIS_PASSWORD (ACL);
   # REDIS_TLS=1 + REDIS_CACERT (+ REDIS_CERT, REDIS_KEY); REDIS_CLUSTER=1 for a cluster.
   REDIS_HOST=redis.example.internal REDIS_PASSWORD=... deploy/scripts/redis-delete-keys.sh 'spring:session:*'
   REDIS_HOST=redis.example.internal REDIS_PASSWORD=... deploy/scripts/redis-delete-keys.sh 'helix:authz:*'

   # or inside the Redis pod (no local redis-cli needed):
   kubectl -n <namespace> exec -i deploy/redis -- sh -s 'spring:session:*' < deploy/scripts/redis-delete-keys.sh
   ```
3. `helm rollback helixiam <revision before the upgrade>` (`helm history helixiam` lists it).
4. Check the pods are back; if the replica count stayed at 0, `kubectl -n <namespace> scale deploy/helixiam --replicas=<n>`.

Restoring the backup instead undoes every other change made since the upgrade (users, clients, settings, audit log): use it only if nothing else changed.

## 14. Renamed Spring properties

*rc.9.* Only if you set Spring properties **directly** (environment variables `SPRING_*` / `MANAGEMENT_*`, or a mounted `application.properties`). The `HELIX_*` settings and the Helm chart are unchanged.

| Spring Boot 3.5 (rc.8) | Spring Boot 4 (rc.9) |
| --- | --- |
| `server.error.*` (e.g. `SERVER_ERROR_INCLUDE_MESSAGE`) | `spring.web.error.*` (`SPRING_WEB_ERROR_INCLUDE_MESSAGE`); the old keys are ignored |
| `spring.session.redis.*` | `spring.session.data.redis.*` |
| `spring.jackson.*` (the application's JSON mapper) | `spring.jackson2.*` (`spring.jackson.*` now configures Boot's Jackson 3 mapper only) |
| `spring.dao.exceptiontranslation.enabled` | `spring.persistence.exceptiontranslation.enabled` |

> **This row is reversed again as of rc.11.** rc.9/rc.10 (above) ran the application's own mapper on Jackson 2 under `spring.jackson2.*`, with `spring.jackson.*` configuring only Boot's `JsonMapper`. rc.11 moves the application itself onto Jackson 3: `spring-boot-jackson2` is removed, and `spring.jackson.*` once again configures the application's mapper (as well as Boot's). There is no `spring.jackson2.*` property any more. See [Upgrading from rc.10 to rc.11](upgrade-rc10-rc11.md#7-jackson-3) for the full detail, including the new 500-level nesting limit and the unchanged 20 MB string limit.

## 15. SMTP relays pass the egress guard

*rc.9.* Until rc.8 the SMTP driver connected to any host and port a realm admin typed, loopback and cluster-internal addresses included. Now every relay — a realm's SMTP provider **and the operator's global relay** (`helix.notification.smtp.*`, used when a realm has no email provider) — passes the outbound guard: the name is resolved once, the connection goes to the checked address, and TLS (SMTPS or STARTTLS) still verifies the configured host name.

| Setting | Helm | Default | Meaning |
| --- | --- | --- | --- |
| `HELIX_EGRESS_ALLOWED_PRIVATE_HOSTS` (`helix.egress.allowed-private-hosts`) | `config.egressAllowedPrivateHosts` | empty | private or loopback hosts outbound calls may reach; exact host names or IP literals, optionally `host:port` |
| `HELIX_MESSAGING_SMTP_ALLOWED_PORTS` (`helix.messaging.smtp.allowed-ports`) | — (`config.extraEnv`) | empty | SMTP ports allowed **besides** 25, 465, 587 and 2525; ports or ranges, e.g. `1025,8025-8030` |

**What you must do.** List an in-cluster or `localhost` relay, preferably as `host:port` so only that port opens:

```bash
HELIX_EGRESS_ALLOWED_PRIVATE_HOSTS=mailer.mail.svc.cluster.local:587
# a relay on a non-standard port also needs:
HELIX_MESSAGING_SMTP_ALLOWED_PORTS=1025
```

Until then sends fail with result `CONFIGURATION` ("not allowed by the egress policy"). Both are deployment settings: a realm admin cannot widen them. In Helm, also allow the port in `networkPolicy.extraEgressPorts`.

## 16. LDAP directories pass the egress guard

*rc.9.* An LDAP federation provider used to let JNDI connect to any URL a realm admin typed. Now only one `ldap://` or `ldaps://` URL is accepted, the host is resolved once and both binds connect to the checked address, LDAPS verifies the certificate against the configured name, and referrals are ignored. A private or loopback directory — the usual Active Directory setup — must be listed:

| Setting | Helm | Default | Format |
| --- | --- | --- | --- |
| `HELIX_FEDERATION_LDAP_ALLOWED_HOSTS` (`helix.federation.ldap.allowed-hosts`) | `config.ldapAllowedHosts` | empty (public directories only) | host names (`ldap.corp`, `ldap.corp:636`) or CIDR ranges (`10.20.0.0/16`) |

```bash
helm upgrade helixiam deploy/helm/helixiam --reuse-values --set 'config.ldapAllowedHosts={ldap.corp,10.20.0.0/16}'
```

Until then LDAP logins for that provider fail (logged as refused by the egress policy). Metadata and link-local addresses stay refused even inside a listed range. Also allow 389/636 in `networkPolicy.extraEgressPorts`.

## 17. Outbound fetch limits

*rc.9.* Server-initiated calls run in feature groups, each with a total limit and a per-realm share, so one realm's slow endpoints cannot delay another realm or any login:

| Group | Used by | `helix.outbound.fetch.<group>.max-concurrent` | `.per-realm-max` |
| --- | --- | --- | --- |
| `login-admin` | OIDC broker, eID, workload identity | 32 | 8 |
| `login-client` | client-assertion JWKS, FAPI request-object JWKS | 32 | 8 |
| `otp` | OTP delivery | 32 | 8 |
| `outbound` | webhooks, SCIM, logout | 32 | 8 |
| `ldap` | LDAP binds (one 15 s deadline per bind sequence) | 32 | 8 |
| `wallet` | wallet status lists and issuer revocation | 16 | = max-concurrent |

**What you must do.** Only if you tuned it: `helix.outbound.fetch.login.*` is split into `login-admin` and `login-client`. The old `login` names are still read as `login-admin`; `helix.wallet.fetch.max-concurrent` is still read as `helix.outbound.fetch.wallet.max-concurrent`. Webhook and SCIM queues also hold at most 1,000 pending deliveries per realm and `helix.outbound.deliveries.max-pending` (20,000) in total.

## 18. Client JWKS URLs must be https

*rc.9.* A `private_key_jwt` client's JWKS URL is chosen by whoever registers the client, so the token endpoint now fetches it through the outbound guard, without redirects.

- Admin API, console and realm-import client writes need an `https` `jwksUrl` (`http://localhost` / `http://127.0.0.1` only in `dev`). A realm import reports such a client as a failed `clients` entry (`jwksUrl: The JWKS URL must be an https URL.`).
- Flyway `V80` rewrites a DCR-stored lowercase `private_key_jwt` auth method to `PRIVATE_KEY_JWT`. The auth method is matched case-insensitively; the admin API and realm import refuse unknown values. Dynamic Client Registration accepts only `client_secret_basic`, `private_key_jwt` and `none`, and requires an https `jwks_uri` the guard allows.
- **A client stored earlier with an `http` JWKS URL can no longer authenticate outside `dev`**: the token endpoint refuses to fetch it (`invalid_client`). At startup the server logs a WARN (`ClientAuthMethodAudit`) for every such client, and for every `private_key_jwt` client without a JWKS URL. **Change their JWKS URL to https before upgrading.**

## 19. `actor_token` is refused on `/oauth2/token`

*rc.9.* A token exchange on `/oauth2/token` with `actor_token` or `actor_token_type` is refused with `invalid_request`. Plain token exchange (`subject_token` only) still works. HelixIAM's supported on-behalf-of delegation is `/agent/delegation/token` — see [Agent authorization](../integration/agent-authorization.md).

## 20. Wallet issuer revocation defaults to `HARD_FAIL`

*rc.9.* An [EU Digital Identity Wallet](../federation/eudi-wallet.md) connection without an explicit `revocationChecking` now checks issuer certificates with `HARD_FAIL` (was `SOFT_FAIL`): an issuer certificate whose CRL / OCSP status cannot be determined — including one that names no CRL or OCSP endpoint — is refused. For an issuer that publishes neither, set `revocationChecking` to `SOFT_FAIL` (console: **Credentials → Issuer certificate revocation**).

---

## Related changes you may notice

These need no action but change behaviour you may see:

- **Federated logins get a new session id** at completion and again after a post-login step-up (OIDC, SAML, eID, wallet).
- **MFA after a federated sign-in.** The post-broker second factor is now enforced for users with MFA enabled, and in realms that require MFA, a user who has not enrolled is sent to `/mfa/enable` after a federated, password, flow-engine or `/flow` sign-in instead of getting a full session.
- **Console-created identity providers load in every realm** (`HELIX_FEDERATION_STORE_ENABLED`, default `true`). With several replicas, an identity-provider change reaches every replica within `helix.federation.store.poll-interval-seconds` (default 10).
- **Broker start errors** send the browser to `/login?error=federation` instead of an error page.
- **eID artifact binding.** The back-channel `ArtifactResponse` of classic DigiD must carry a valid signature by the IdP's metadata signing key; an unsigned response, or any response when the connection has no IdP signing certificate, is refused.
- **ID tokens carry `acr`** for brokered eID logins whose provider asserted a recognised level, and for wallet logins.
- **APNs push** goes only to `api.push.apple.com` / `api.sandbox.push.apple.com` (HTTP/2, no redirects); a provider `host` naming anything else is a configuration error.
- **Outbound calls are pinned** (*rc.9*): webhooks, SCIM, HTTP email/SMS/push, logout, OIDC broker and workload-identity JWKS, eID artifact resolution, SAML metadata import, wallet status lists and CRL/OCSP connect to the address the guard checked (no DNS rebinding), follow no redirects, and IPv6 forms that embed an IPv4 address are judged by that address.
- **Helm** (*rc.9*): new values `strategy` (rendered in full; default Kubernetes' RollingUpdate 25% / 25%) and `enableServiceLinks` (default `false`, so a Service named `redis` no longer injects `REDIS_PORT=tcp://…`).

## See also

- [Upgrades & migrations](upgrades.md) — the general rolling-upgrade procedure
- [Security hardening](security.md) — the production checklist, including proxies and host names
- [Configuration](../getting-started/configuration.md) — every variable on this page, with defaults
- [Install](../getting-started/install.md) — Helm values `config.trustedProxies`, `config.allowedHosts`, `config.ldapAllowedHosts`, `strategy`
