# Security hardening

A production checklist to lock down a HelixIAM deployment, plus the high-assurance protocol profiles it supports.

## Production checklist

Work through this before you serve real traffic.

### Secrets & keys

!!! danger "DB_ENCRYPTION is immutable after first boot"
    Keep `DB_ENCRYPTION` strong, store it securely, back it up, and **never change it** once the system has booted. Rotating or losing it makes every encrypted column — and every database backup — unrecoverable. See [Backup & disaster recovery](backup.md).

- **Adding a key to an install that ran without one?** Secret rows written in plaintext then fail to read. Use the time-boxed `HELIX_DB_ENCRYPTION_MIGRATE_PLAINTEXT` / `HELIX_DB_ENCRYPTION_MIGRATE_UNTIL` window and unset it once the rows are rewritten — see the [upgrade guide](upgrade-rc8-rc9.md#5-plaintext-secret-rows-a-time-boxed-migration-window).
- **Legacy encryption is retired.** Values in the old AES/ECB format are re-encrypted to AES-GCM at every startup and then no longer read. Check the first start's log for a readiness WARN naming rows it could not read; only then consider `HELIX_DB_ENCRYPTION_ALLOW_LEGACY=true` while you investigate.

!!! warning "Back up the realm keys"
    Realm signing keys are stored in PostgreSQL, encrypted with `DB_ENCRYPTION`. Back up the database and keep the `DB_ENCRYPTION` value safe: losing either loses the keys and invalidates every issued token. See [Realm keys](realm-keys.md). To keep a realm's keys in an HSM or in Vault / OpenBao instead, see [Key custody (HSM / KMS)](key-custody.md).

### Accounts & access

- **Set `HELIX_ADMIN_PASSWORD`** for any non-local install. If you leave it unset, the first boot generates a strong password and writes it (mode `0600`) to `HELIX_ADMIN_PASSWORD_FILE` (default `$TMPDIR/helixiam-admin-password`, `/tmp/helixiam-admin-password` in the image); it is never logged. Read it, sign in, and **change it**.
- **Change the admin password** from any bootstrap default, and rotate it on a schedule.
- Keep the **`dev-open` admin bypass OFF** — it is off by default and must stay off in production.
- Role names starting with **`FACTOR_`** (any case) are reserved for sign-in factors: such a role grants nothing and is refused on create. The query under [Rename roles named `FACTOR_…`](upgrade-rc8-rc9.md) lists existing ones.
- Apply least-privilege [admin RBAC](admin-roles.md) so each administrator holds only the permissions they need.

### Network & endpoints

- Use real **HTTPS hostnames** for `IDP_BASE_URL` and `SP_BASE_URL` so issuer, redirect and metadata URLs are correct and trusted.

```bash
IDP_BASE_URL=https://login.example.com
SP_BASE_URL=https://login.example.com
```

### Reverse proxies and host names

The server trusts forwarded headers only from proxies you name, and answers only for its own host names. Both matter for the OIDC issuer, the Secure-cookie decision, every absolute URL, and the client IP used by rate limits, the audit log, risk signals and CAPTCHA.

| Setting | Helm | Default | What it does |
| --- | --- | --- | --- |
| `HELIX_TRUSTED_PROXIES` | `config.trustedProxies` | empty | IPs or CIDR ranges (IPv4/IPv6) whose `X-Forwarded-For` and `Forwarded` / `X-Forwarded-Proto` / `-Host` / `-Port` / `-Prefix` are honoured; from anyone else they are removed |
| `HELIX_ALLOWED_HOSTS` | `config.allowedHosts` | empty | host names answered besides the hosts of `IDP_BASE_URL` and `SP_BASE_URL`; `name` = any port, `name:port` = that port |

- **Behind a TLS-terminating ingress, load balancer, Gateway or mesh, set `HELIX_TRUSTED_PROXIES`** to the addresses it connects from (for example the ingress controller's pod CIDR). Left empty, the server sees plain HTTP (issuer and redirects become `http://`) and every client shares the proxy's single sign-in rate-limit bucket. The server logs an ERROR, at most once a minute, when an `https` `IDP_BASE_URL` is reached over HTTP.
- **Never list addresses clients can reach the server from directly.**
- **The proxy must append to `X-Forwarded-For`.** `X-Real-IP` is never read. From a trusted proxy, `X-Forwarded-For` is read from the right; the first address that is not a trusted proxy is the client.
- **Outside the `dev` profile, any other `Host` gets `400`.** A host of `IDP_BASE_URL` / `SP_BASE_URL` is accepted only on that URL's port. List in-cluster service names that relying parties call in `HELIX_ALLOWED_HOSTS`. Probes keep working: the connection's own address and `localhost` from a loopback peer (on the arriving or default port), and `/actuator/health/**` on any host.
- Only `server.forward-headers-strategy=framework` (the default) is supported.

### Rate limits

Sensitive endpoints are rate limited **per client IP** and endpoint group. The budgets are counted once for the whole cluster, in PostgreSQL or Redis (see [High availability](high-availability.md)); a single replica with in-memory state counts on its own. Up to and including 1.0.0-rc.12 each replica counted separately, so the effective limit was the configured one times the number of replicas. Excess requests get `429`.

| Endpoints | Burst | Refill / minute | Properties |
| --- | --- | --- | --- |
| sign-in: `/login`, OTP/flow submit, password reset, registration | 20 | 20 | `helix.ratelimit.login.*` |
| `/oauth2/token` (all grants) | 120 | 120 | `helix.ratelimit.token.*` |
| EU Digital Identity Wallet: `/oid4vp/**` | 120 | 120 | `helix.ratelimit.wallet.*` |
| starting a wallet login (`GET /broker/<wallet alias>`) | 30 | 30 | `helix.ratelimit.wallet-start.*` |

Each group has `.burst` and `.refill-per-minute`. In-cluster callers usually share one egress or pod IP: raise `helix.ratelimit.token.*` for many backend services on one node, or route them through a trusted proxy that forwards their address. `helix.ratelimit.enabled=false` turns the limiter off (only behind another rate-limiting layer). The limit is per IP, not per OAuth client.

### Outbound calls (SSRF)

Admins, realm admins and anyone who can register a client choose URLs the server will call. Every such call — webhooks, SCIM, HTTP email/SMS/push, SMTP relays, LDAP binds, OIDC/eID broker and client JWKS, logout, wallet status lists and CRL/OCSP — passes an egress guard: loopback, private, link-local and cloud-metadata destinations are refused (IPv6 forms that embed an IPv4 address are judged by that address), the host is resolved once and the connection goes to the checked address (no DNS rebinding), redirects are not followed, and TLS is verified against the configured host name.

- **Allow only what you need.** Internal targets go in `HELIX_EGRESS_ALLOWED_PRIVATE_HOSTS` as `host:port`; internal LDAP directories in `HELIX_FEDERATION_LDAP_ALLOWED_HOSTS`; extra SMTP ports in `HELIX_MESSAGING_SMTP_ALLOWED_PORTS`. Don't use the deprecated `HELIX_EGRESS_ALLOW_PRIVATE=true`.
- **Client JWKS URLs must be https** (`private_key_jwt`); a client stored with an `http` JWKS URL cannot authenticate outside `dev`. Check the startup log for `ClientAuthMethodAudit` warnings.
- **APNs** push goes only to Apple's hosts (`api.push.apple.com`, `api.sandbox.push.apple.com`).
- Each feature group has its own concurrency limit and per-realm share (`helix.outbound.fetch.<group>.*`), so one realm's slow endpoints cannot hold up another realm's logins.

### Data stores and network

- **Change database and Redis credentials** from their defaults, and **do not expose PostgreSQL or Redis** to untrusted networks.
- Forward the [audit log to your SIEM](observability.md) for monitoring and incident response.

## Verify the hardening

Don't take the checklist on trust — probe the running server. These checks use anonymous endpoints (no cookie, no token); see [Authenticating to the API](../getting-started/api-authentication.md) for `$HELIX_URL` / `$REALM`.

### The admin API rejects anonymous callers

An unauthenticated call to any `/admin/**` route must return **401**, never data:

```bash
curl -s -o /dev/null -w '%{http_code}\n' "$HELIX_URL/admin/realms/$REALM/users"
```
```
401
```

Anything other than `401`/`403` here means the admin surface is reachable without a session — stop and fix your reverse proxy / network policy before serving traffic.

### Discovery advertises the expected profile

The realm's discovery document is the authoritative statement of what the issuer supports. Confirm the high-assurance building blocks are present — S256 PKCE, sender-constraint auth methods, mTLS-bound tokens, and a PAR endpoint:

```bash
curl -s "$HELIX_URL/realms/$REALM/.well-known/openid-configuration" | jq '{
  issuer,
  code_challenge_methods_supported,
  token_endpoint_auth_methods_supported,
  tls_client_certificate_bound_access_tokens,
  pushed_authorization_request_endpoint
}'
```
```json
{
  "issuer": "http://localhost:8080/realms/master",
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic", "client_secret_post", "client_secret_jwt",
    "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth"
  ],
  "tls_client_certificate_bound_access_tokens": true,
  "pushed_authorization_request_endpoint": "http://localhost:8080/realms/master/oauth2/par"
}
```

Check the `issuer` is your real HTTPS hostname (from `IDP_BASE_URL`), that `code_challenge_methods_supported` is `["S256"]` (never `plain`), and that `private_key_jwt` / `tls_client_auth` are offered for high-assurance clients. See [OpenID / FAPI conformance](../compliance/conformance.md) for the full advertised feature set.

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

Outside the `dev` profile, a request for an unknown `Host` is refused before any issuer or URL is derived from it:

```bash
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.example' \
  "$HELIX_URL/realms/$REALM/.well-known/openid-configuration"
```
```
400
```

A `200` here means the server runs with the `dev` profile, or the name is in `HELIX_ALLOWED_HOSTS`. Then confirm the discovery `issuer` above starts with `https://`: an `http://` issuer behind a TLS proxy means `HELIX_TRUSTED_PROXIES` is missing.

## High-assurance protocol profiles

For regulated and high-value clients, HelixIAM supports advanced, sender-constrained and bank-grade OAuth/OIDC profiles:

- **FAPI** — mutual-TLS-bound tokens, signed request objects, and JWT-secured response mode (**JARM**).
- **DPoP** — sender-constrained tokens bound to a client-held key.
- **PAR** — pushed authorization requests, so request parameters never travel through the browser.
- **CIBA** — decoupled, backchannel authentication for out-of-band approval flows.

Enable these per client when the relying party requires a high-assurance profile.

## See also

- [Realm keys](realm-keys.md)
- [Admin roles (RBAC)](admin-roles.md)
- [Backup & disaster recovery](backup.md)
- [Observability & health](observability.md)
- [Multi-factor authentication](../authentication/mfa.md)
- [Configuration](../getting-started/configuration.md)
- [Upgrading to rc.8 / rc.9](upgrade-rc8-rc9.md)
