# Realm keys

Every realm signs its tokens **and its SAML assertions** with its own key. You can let HelixIAM generate keys, bring
your own key and certificate, or have HelixIAM create a key and a certificate signing request (CSR) for your CA — and
roll over to a new key without downtime.

## What it is

Each realm owns its own signing keys and publishes the public half in its **JWKS** and its **SAML metadata**. Relying
parties verify the realm's tokens and assertions with those, so realms are isolated: one realm can never verify
another's tokens.

- **An active RS256 key, always — plus an optional ES256 key.** OpenID Connect requires RS256, so every realm keeps
  exactly one ACTIVE RSA key (RS256, with PS256 published alongside for FAPI clients). A realm may add exactly one
  ACTIVE EC P-256 key (ES256) beside it; an EC key can only be activated while an ACTIVE RSA key exists.
- **Who signs what.** ID tokens use the client's ID-token algorithm (`id_token_signed_response_alg`, RS256 by
  default). Access tokens, logout tokens, delegation and workload-identity tokens and JARM responses use the realm's
  **default signing algorithm**. SAML signs with the ACTIVE RSA key unless the realm chooses ES256 for SAML. See
  [Signing settings](#signing-settings).
- **Staged rollover.** A new key starts as **PENDING**: it is published (JWKS and SAML metadata) but does not sign.
  When relying parties have picked it up, you **activate** it; the previous key becomes **retained** (status
  `ROTATED`) and stays published until you retire it, so tokens it signed keep verifying.
- **Where keys live.** In PostgreSQL (`realm_key`), the private key encrypted with `DB_ENCRYPTION`. Imported and
  CSR keys are refused when `DB_ENCRYPTION` is not set (outside the dev profile). The private key is never returned
  by the API, shown in the console, logged or written to the audit log.

| Status | Published in JWKS and SAML metadata | Signs |
| --- | --- | --- |
| `PENDING` | yes | no |
| `ACTIVE` (one RSA, at most one EC) | yes (listed first) | yes |
| `ROTATED` (console: *Retained*) | yes | no |
| `RETIRED` | no | no |

## Where relying parties get the keys

| What | Where |
| --- | --- |
| OIDC/OAuth2 token signing keys (JWKS) | `/realms/<realm>/oauth2/jwks`, also advertised as `jwks_uri` in `/realms/<realm>/.well-known/openid-configuration` |
| Signing algorithms | `id_token_signing_alg_values_supported` in the discovery document: exactly the algorithms of the ACTIVE keys (`RS256`, `PS256`, and `ES256` while an EC key is active); PENDING and retained keys are not advertised |
| SAML signing certificates | `/realms/<realm>/saml/idp/metadata`, one `<KeyDescriptor use="signing">` per published key (ACTIVE first) |

Every JWK carries `kid`, `alg` and `use: sig`. A key with a certificate chain also carries `x5c` (standard Base64
DER, leaf first, without a trailing self-signed root) and `x5t#S256`. The `kid` of an RSA key is derived from its
modulus (as in earlier releases, so existing tokens keep verifying); the `kid` of an EC key is its RFC 7638
thumbprint. The console shows the `kid` of every key.

## SAML assertion signing

The realm signs SAML with its ACTIVE **RSA** key — or with its ACTIVE EC key when the realm's SAML signing algorithm is
ES256 (see [Signing settings](#signing-settings)) — and publishes that key's certificate first in the IdP metadata:

- **With a certificate** (imported, or CA-signed after a CSR): the metadata and the signatures' `KeyInfo` carry that
  **leaf certificate**.
- **Without a certificate** (generated keys, or a CSR key before its certificate comes back): HelixIAM publishes a
  deterministic **self-signed** certificate around the key (subject `CN=helix-idp-<realm>`, valid 2000-01-01 to
  2099-12-31), exactly as before. A PENDING key without a certificate is published with such a self-signed
  certificate too, so a metadata-reading SP can roll over to it either way.
- PENDING and retained keys are published as **additional** `<KeyDescriptor use="signing">` elements, so service
  providers that re-read the metadata trust the next key before it signs.
- The `<KeyDescriptor use="encryption">` is published only while SAML signs with an RSA certificate whose keyUsage
  allows keyEncipherment (or has no keyUsage extension).

!!! warning "Service providers should read live metadata — not pin the certificate"
    An SP that consumes the realm's metadata URL and re-reads it picks up a staged key before you activate it. An SP
    configured with a pinned copy of the certificate rejects assertions as soon as you activate a different key (or
    attach a renewed certificate to the active key): give it the new certificate first. See
    [SAML clients](../manage/saml-clients.md).

!!! warning "EC keys and SAML"
    With the SAML signing algorithm set to ES256, SAML messages are signed with
    `http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256`, whatever RSA algorithm a SAML client's options name. Many
    service providers accept RSA signatures only: check every connected SP first. The default stays RSA. HelixIAM's
    own sandbox relying party (a test harness) verifies RSA SAML signatures only.

## In the console

Open **Manage → Realms → Realm keys**. Changing keys needs the **`realm-admin`** permission (whoever controls the
signing key can issue any token of the realm); admins with `manage-realm` only see the keys, read-only. Each key shows its status (Pending / Active / Retained / Retired), algorithm,
certificate subject and issuer, expiry (with a warning chip when it expires within 30 days and a red chip when it has
expired), and SHA-256 fingerprint; the `kid` and key id are in the muted line under its name. From here you can:

- **Import key and certificate** — paste a PEM private key and its PEM certificate chain.
- **Create CSR** — choose RS256 or ES256, enter the subject (and DNS names); copy or download the CSR.
- **Upload signed certificate** — attach the chain your CA returned (pending or active keys).
- **Activate** — a pending key starts signing; a confirmation explains the effect, including the SAML note above. It
  shows how long the key has been published; before the minimum staging time it asks you to confirm a forced
  activation.
- **Rotate (generate)** — generate a new key and activate it at once (no staging).
- **Retire** — drop a pending or retained key (or the active ES256 key) from the JWKS and metadata. The active RS256
  key cannot be retired.

!!! warning "Back up the database and keep DB_ENCRYPTION"
    Realm keys live in PostgreSQL, encrypted with `DB_ENCRYPTION`. Losing the database, or the `DB_ENCRYPTION` value,
    loses the keys: every issued token and every cached JWKS stops validating, and SAML service providers must re-read
    metadata. Back up both. See the [security checklist](security.md) and [backup guide](backup.md).

## Bring your own key and certificate

`POST /admin/realms/{realm}/keys/import` takes `{privateKeyPem, certificateChainPem}`.

- **Private key:** exactly one unencrypted PEM key, with nothing before or after it — PKCS#8 (`BEGIN PRIVATE KEY`),
  PKCS#1 (`BEGIN RSA PRIVATE KEY`) or SEC1 (`BEGIN EC PRIVATE KEY`). RSA must be 2048 to **4096** bits with a public
  exponent of at least 65537 (odd, at most 32 bits); EC must be P-256. Encrypted PEM is refused (decrypt it first,
  for example `openssl pkey -in key.pem -out plain.pem`).
- **Certificate chain:** PEM certificates, **leaf first**, each certificate issued by the next one. The leaf must be
  for this private key, must not be a CA certificate (`CA:true`), and must have `digitalSignature` if it has a
  keyUsage extension. Every following certificate must be a CA (`CA:true`, and `keyCertSign` if it has a keyUsage).
  Every certificate of the chain must be valid now (an expired or not-yet-valid certificate is refused). A single
  self-signed certificate is accepted.
- A key that is already stored (in this realm or any other) is refused: every realm needs its own key.

Refusals are `400 {message, fieldErrors}` on `privateKeyPem` or `certificateChainPem`. The examples below assume you
have set `$HELIX_URL` / `$REALM` and logged in — see [Authenticating to the API](../getting-started/api-authentication.md).

```bash
# Refresh CSRF after any GET, then POST (see the auth guide for the helper)
curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/keys" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

jq -n --rawfile key key.pem --rawfile chain chain.pem \
  '{privateKeyPem: $key, certificateChainPem: $chain}' |
curl -s -b cookies.txt -X POST -H "X-XSRF-TOKEN: $CSRF" -H "Content-Type: application/json" \
  --data-binary @- "$HELIX_URL/admin/realms/$REALM/keys/import"
```
```json
{
  "keyId": "3f2c9a10-6b1e-4d8f-9c55-1a7e0b2d4c61",
  "algorithm": "RS256",
  "status": "PENDING",
  "publicKey": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwV1W…AB",
  "createdAt": 1792164099009,
  "rotatedAt": null,
  "kid": "tnydxwrdKX5Vlb6cLmgx1zTxt/GRcLtjNpJlR+jaqeU=",
  "source": "IMPORTED",
  "activatedAt": null,
  "hasCsr": false,
  "publicKeyFingerprint": "41:D1:C6:A4:…:9F:2F",
  "certificate": {
    "subject": "CN=idp.example.com,O=Example",
    "issuer": "CN=Example Issuing CA",
    "serialNumber": "1a1454db2d3",
    "notBefore": 1792077698000,
    "notAfter": 1823613698000,
    "sha256Fingerprint": "66:A8:B6:40:…:3B:B6",
    "selfSigned": false,
    "chainLength": 2,
    "expiry": "OK"
  }
}
```

`certificate.expiry` is `OK`, `EXPIRING` (within 30 days) or `EXPIRED`.

## Have HelixIAM create the key and a CSR

The private key never leaves the server: HelixIAM generates it (RSA-2048 for RS256, P-256 for ES256), stores it as
PENDING and returns a PKCS#10 CSR (signed with SHA256withRSA / SHA256withECDSA, with a keyUsage=digitalSignature
request and the DNS names as subjectAltName). The subject may use the attributes CN, O, OU, C, L, ST,
emailAddress, SERIALNUMBER, DC and UID (no raw OIDs, no `#hex` values); C must be a two-letter country code.

```bash
curl -s -b cookies.txt -X POST -H "X-XSRF-TOKEN: $CSRF" -H "Content-Type: application/json" \
  -d '{"algorithm":"RS256","subject":"CN=idp.example.com, O=Example, C=NL","dnsNames":["idp.example.com"]}' \
  "$HELIX_URL/admin/realms/$REALM/keys/csr" | tee csr-response.json | jq -r .csrPem > idp.csr
KEY_ID=$(jq -r .key.keyId csr-response.json)
```

Send `idp.csr` to your CA. You can download the CSR again later:

```bash
curl -s -b cookies.txt "$HELIX_URL/admin/realms/$REALM/keys/$KEY_ID/csr" | jq -r .csrPem
```

When the signed certificate comes back, attach it (leaf first, then the issuing CA certificates). The leaf must be
for this key; the same chain rules as for an import apply:

```bash
jq -n --rawfile chain signed-chain.pem '{certificateChainPem: $chain}' |
curl -s -b cookies.txt -X PUT -H "X-XSRF-TOKEN: $CSRF" -H "Content-Type: application/json" \
  --data-binary @- "$HELIX_URL/admin/realms/$REALM/keys/$KEY_ID/certificate"
```

You can also attach a renewed certificate to the **active** key (same key, new certificate). Tokens do not change;
the SAML metadata and signatures switch to the new certificate at once.

## Activate a staged key

Wait until your relying parties have picked up the PENDING key (OIDC verifiers that read the live JWKS do so within
their cache time; SAML SPs when they re-read metadata), then:

```bash
curl -s -b cookies.txt -X POST -H "X-XSRF-TOKEN: $CSRF" \
  "$HELIX_URL/admin/realms/$REALM/keys/$KEY_ID/activate"
```

The key becomes `ACTIVE`; the previous ACTIVE key **of the same algorithm** (RSA or EC) becomes `ROTATED` and stays
published. Only a PENDING key can be activated, and an ES256 key only while an RS256 key is active (anything else is
`409` with the reason). Activations, rotations and retirements of one realm are serialised, so two concurrent
activations never leave two active keys. With several replicas, each replica switches within its 30-second key
cache; because the new key was already published, verifiers accept tokens from either replica.

**Minimum staging time.** A key must have been published for `helix.keys.min-staging` (default 5 minutes,
`HELIX_KEYS_MIN_STAGING`, an ISO-8601 duration such as `PT10M`) before it can be activated; earlier is `409` with
`"error": "staging"`. To activate anyway, add `?force=true`; the audit event then records `forced=true`:

```bash
curl -s -b cookies.txt -X POST -H "X-XSRF-TOKEN: $CSRF" \
  "$HELIX_URL/admin/realms/$REALM/keys/$KEY_ID/activate?force=true"
```

## Rotate (generate and activate at once)

```bash
curl -s -b cookies.txt -X POST -H "X-XSRF-TOKEN: $CSRF" -H "Content-Type: application/json" \
  -d '{"algorithm":"RS256"}' \
  "$HELIX_URL/admin/realms/$REALM/keys/rotate"
```

The body is optional (RS256 by default; `ES256` generates an EC P-256 key, which needs an active RS256 key). Rotation
replaces the active key of that algorithm and **has no staging**: the new key signs immediately, before any verifier
has fetched it, so verifiers with a cached JWKS refetch it, and a SAML SP that pinned the certificate breaks at once.
Prefer a CSR or an import plus activation. `HELIX_ROTATE_REALM=<realm>` still forces one RS256 rotation at startup;
clear it afterwards.

## Retire an old key

Once the longest-lived token signed by a retained key has expired, retire it:

```bash
# Returns 204 No Content; the ACTIVE RS256 key is refused with 409
curl -s -b cookies.txt -X DELETE -H "X-XSRF-TOKEN: $CSRF" \
  "$HELIX_URL/admin/realms/$REALM/keys/$KEY_ID"
```

A PENDING key can be retired too (to discard a staged key), and so can the ACTIVE ES256 key unless the realm's
signing settings choose ES256 (then `409`). While clients are registered for ES256 ID tokens, retiring the ACTIVE
ES256 key is `409` with their number and names; add `?force=true` to retire it anyway (those clients then get RS256
ID tokens; the audit event records `forced=true`). The ACTIVE RS256 key is never retired: OpenID Connect requires RS256. Key
ids are scoped to the realm: a key id of another realm is `404` on every key route.

## ES256 alongside RS256

ES256 is offered **alongside** RS256, never instead of it:

- Discovery lists exactly the algorithms of the ACTIVE keys: `RS256` and `PS256` always, plus `ES256` while an EC key
  is active.
- A client gets ES256 ID tokens only when it is registered for them (`idTokenSignatureAlg: "ES256"` on the client, the
  console's *ID token signature algorithm*). Choosing an algorithm the realm has no ACTIVE key for is refused with
  `400` on `idTokenSignatureAlg`. If the key goes away later, ID tokens fall back to RS256 and the server logs a
  WARN.
- Access tokens and the other realm-signed JWTs follow the realm's default signing algorithm.

## Signing settings

`GET` / `PUT /admin/realms/{realm}/settings/signing` (console: **Realm settings → Tokens → Signing**):

| Setting | Values | Applies to |
| --- | --- | --- |
| `defaultAlg` | `RS256` (default), `PS256`, `ES256` | access tokens, logout tokens, delegation and workload-identity tokens, JARM |
| `samlAlg` | `RS256` (default), `ES256` | SAML assertions, responses and logout messages |

Changing them needs `realm-admin` (reading needs `manage-realm`). ES256 needs an ACTIVE EC key (`400` on the field
otherwise). The response also lists `activeAlgorithms`:

```bash
curl -s -b cookies.txt -X PUT -H "X-XSRF-TOKEN: $CSRF" -H "Content-Type: application/json" \
  -d '{"defaultAlg":"ES256","samlAlg":"RS256"}' \
  "$HELIX_URL/admin/realms/$REALM/settings/signing"
```
```json
{ "defaultAlg": "ES256", "samlAlg": "RS256", "activeAlgorithms": ["RS256", "PS256", "ES256"] }
```

Realm exports do not include the signing settings yet; set them again on the target.

## Certificate expiry

- Import and attach refuse expired or not-yet-valid certificates.
- HelixIAM checks ACTIVE and PENDING keys with a certificate one minute after start-up and every 6 hours
  (`HELIX_KEYS_EXPIRY_CHECK_INTERVAL_HOURS`; `HELIX_KEYS_EXPIRY_CHECK_ENABLED=false` switches it off). While a
  certificate expires within 30 days, or has expired, it writes **one audit event per key per day** —
  `REALM_KEY_CERTIFICATE_EXPIRING` or `REALM_KEY_CERTIFICATE_EXPIRED`, actor `system`, with key id, algorithm,
  fingerprint, `notAfter` and days left — and a WARN log line. With several replicas only one of them warns.
- The `realmSigningKeys` health indicator (part of `/actuator/health/readiness`) reports the counts as details. It is
  always `UP`: certificate expiry never takes a pod out of rotation.
- **An expired ACTIVE certificate does not stop signing.** Token signatures do not depend on certificate validity,
  and stopping would take every login down. The realm keeps signing, warns daily and shows a red banner in the
  console. SAML SPs that check certificate validity reject the realm's assertions until you attach a renewed
  certificate or activate a new key.

## Audit

Every key write is audited with the key id, algorithm and SHA-256 fingerprint (of the certificate, or of the public
key when there is none) — never key material: `REALM_KEY_IMPORT`, `REALM_KEY_CSR`, `REALM_KEY_CERTIFICATE_ATTACH`,
`REALM_KEY_ACTIVATE` (with `forced=true` for a forced activation), `REALM_KEY_ROTATE`, `REALM_KEY_RETIRE`. Should a
realm ever have two ACTIVE keys of one algorithm (for example after manual SQL), HelixIAM signs with the newest,
publishes the other for verification only, logs an ERROR and writes `REALM_KEY_DUPLICATE_ACTIVE` — it never stops
signing.

## Realm export and import

Realm exports contain **no** signing keys and no certificates: private keys never leave the server, and a certificate
without its key is useless on the target. An imported realm generates a fresh key on first use. To use your own key on
the target, import the key and certificate there again (with its own key — the same key cannot be stored twice).

## What you cannot do today

- **HSM and KMS are opt-in per realm.** By default (the Standard profile) keys are software keys in the database,
  encrypted with `DB_ENCRYPTION`. To keep a realm's keys in a PKCS#11 HSM or in Vault / OpenBao Transit, see
  [Key custody](key-custody.md).
- **No scheduled rotation.** Rotation and activation are manual (console or API).
- **No ACME** or other automatic certificate renewal.
- **Algorithms:** RS256 (with its PS256 twin) and ES256 only; RSA keys up to 4096 bits; no RSA-PSS-only keys, no
  P-384/P-521, no EdDSA.
- **No encrypted private-key PEM** on import.
- **Rolling upgrade:** activate an ES256 key only after every replica runs this release; an older replica cannot load
  an EC key.

## Full endpoint set

| Method & path (under `/admin/realms/{realm}`) | Purpose |
| --- | --- |
| `GET /keys` | List keys with status, algorithm, kid and certificate facts |
| `GET /keys/access` | `{"canManage": true|false}`: whether the signed-in admin holds `realm-admin` |
| `POST /keys/import` | Import a private key + certificate chain as PENDING |
| `POST /keys/csr` | Generate a PENDING key and return a PKCS#10 CSR; optional `purpose` (`all`, `access`, `id`, `saml`) for a [custody realm](key-custody.md#keys-per-purpose) |
| `GET /keys/{keyId}/csr` | Download the stored CSR again |
| `PUT /keys/{keyId}/certificate` | Attach a certificate chain to a PENDING or ACTIVE key |
| `POST /keys/{keyId}/activate` | Activate a PENDING key (the ACTIVE key of its algorithm becomes ROTATED); `?force=true` skips the staging time |
| `POST /keys/rotate` | Generate and activate a new key at once (optional `{"algorithm"}`) |
| `DELETE /keys/{keyId}` | Retire a PENDING or ROTATED key, or the ACTIVE ES256 key |
| `GET /settings/signing` · `PUT /settings/signing` | The realm's signing algorithms |

Every write under `/keys`, and `PUT /settings/signing`, needs `realm-admin`; reads need `manage-realm`.

## See also

- [Backup & disaster recovery](backup.md)
- [Security hardening](security.md)
- [Realm settings](realm-settings.md)
- [Configuration](../getting-started/configuration.md)
