Skip to content

FAPI 2.0 & mutual TLS

New in v1.1.0-rc.15

The FAPI 2.0 security profile, FAPI 2.0 Message Signing and mutual TLS arrived with v1.1.0-rc.15. Upgrading: from 1.1.0-rc.14 to 1.1.0-rc.15. Conformance self-run: FAPI 2.0 conformance-suite run.

A realm has a security profile: Standard (the default) or FAPI 2.0. With FAPI 2.0, every client of the realm follows the FAPI 2.0 Security Profile (Final) at the same time. The rules are checked whenever a client is used, whatever the client stored: a client that does not meet them stops working, and nothing falls back to a weaker setting.

FAPI 2.0 mode is not a certification. HelixIAM has not been certified by the OpenID Foundation for FAPI 2.0; the OpenID Foundation's FAPI 2.0 test plans pass in a self-run.

Turning it on

  • Console: Realm settings → Security. Choose FAPI 2.0 and save. The page lists the clients of the realm that would stop working, with the reason, and asks for confirmation if there are any.
  • Admin API: PUT /admin/realms/{realm}/settings/security-profile with {"securityProfile": "FAPI2"} (or "STANDARD"). Needs realm-admin. GET on the same path returns the profile and the clients that would stop working (nonCompliantClients, each with reasons: publicClient, clientSecret, noKeys, implicit).
  • Realm export/import: the export's keyCustody.securityProfile carries the profile (and keyCustody.requireSignedRequestObject, below); an import applies it before the clients, so they are checked against it.
  • Signed requests (FAPI 2.0 Message Signing): in a FAPI 2.0 realm, Require signed request objects (console) or "requireSignedRequestObject": true in the same PUT makes every client of the realm send its authorization requests as signed request objects at PAR (see Message Signing). A single client can require it too (client setting Require signed request objects). In a Standard realm the switch is refused (fieldErrorCodes.requireSignedRequestObject: onlyFapi2); switching the realm back to Standard turns it off.

A refused write answers 400 with fieldErrors.securityProfile and a code in fieldErrorCodes: required, unknown, or masterRealm. The master realm cannot use FAPI 2.0: the admin console signs in to it as a public client.

The setting is stored with the realm's key-custody settings (realm_custody_settings.security_profile, migration V197) and every replica picks up a change at commit.

What a FAPI 2.0 realm enforces

Rule Behaviour Spec
Confidential clients, private_key_jwt or mutual TLS private_key_jwt, tls_client_auth or self_signed_tls_client_auth (RFC 8705 §2). Saving a public client or one with a client secret is refused (400, fieldErrorCodes.publicClient: fapi2PublicClient or fieldErrorCodes.tokenEndpointAuthMethod: fapi2AuthMethod; also in Dynamic Client Registration and the realm import). A client stored before the switch cannot authenticate (invalid_client). §5.3.2.1
Client assertion aud The issuer identifier only, as a single string. A token, PAR or other endpoint URL, or an array, is refused. §5.3.2.1-8
Pushed authorization requests (PAR) A plain /oauth2/authorize request is refused with invalid_request (redirected to a registered redirect URI only). A pushed request needs redirect_uri. The request_uri lives 300 s (expires_in); after that it is removed and answered invalid_request_uri at the pushed request's redirect URI. §5.3.2.2, RFC 9126
PKCE S256 required, for confidential clients too. A missing or wrong code_verifier is refused. §5.3.2.2
Response type code only. The implicit and hybrid flows are refused, at use and when a client is saved (implicitFapi2). §5.3.2.2
Authorization code Lives at most 60 s, single use, bound to its client. §5.3.2.1
Sender-constrained tokens Every access token is sender-constrained, by DPoP or by mutual TLS, chosen per client. DPoP (default): bound to the client's DPoP key (cnf.jkt, token_type: DPoP); a token request without a proof is refused with invalid_dpop_proof; userinfo takes the token with its proof (Authorization: DPoP) and refuses it as Bearer. Mutual TLS (client setting certificate-bound access tokens): bound to the client certificate (cnf.x5t#S256); a token request without a certificate is refused with invalid_request; userinfo takes the token only with that same certificate. Never an unbound Bearer token. §5.3.2.1, §5.3.3, RFC 9449, RFC 8705 §3
Other token endpoints The agent delegation endpoint (/agent/delegation/token) issues a FAPI 2.0 client sender-constrained tokens only: without a DPoP proof (or the certificate of a certificate-bound client) it is refused with invalid_dpop_proof (invalid_request for the certificate). Workload identity federation (/workload-identity/token) is refused with unsupported_grant_type: the workload authenticates with an external JWT, neither private_key_jwt nor mutual TLS, and its tokens are not sender-constrained. §5.3.2.1-6, §5.3.3
dpop_jkt A DPoP proof sent with the pushed request binds the code to its key; a dpop_jkt that names another key is refused at PAR; the code is redeemed only with a proof of that key. RFC 9449 §10
Refresh tokens Not rotated (the same refresh token keeps working). With mutual TLS the refresh needs the client certificate of the access token (another certificate: invalid_grant). With DPoP the refresh needs a DPoP proof, and the new access token is bound to that proof's key: the refresh token of a confidential client is not bound to a DPoP key, since the client authenticates (RFC 9449 §5). §5.3.2.1, RFC 9449 §5
Algorithms The realm signs ID tokens, access tokens and other JWTs with PS256, ES256 or EdDSA: RS256 is signed as PS256 with the same RSA key, and there is no RS256 fallback. Client assertions and request objects are accepted with PS256, ES256 or EdDSA (RSA keys of 2048 bits or more); DPoP proofs with PS256 or ES256. none is never accepted. §5.4.1
Clock skew A JWT whose iat or nbf is up to 10 s in the future is accepted; more than 60 s is refused (client assertions, DPoP proofs, request objects). §5.3.2.1
Redirects Redirects to the client's redirect URIs (responses and errors) are 303 See Other, never 307. §5.3.2.2
Discovery Advertises exactly the above: require_pushed_authorization_requests: true, private_key_jwt, and when a client certificate can reach the server (a forwarded-certificate header or the server's own TLS client authentication) self_signed_tls_client_auth (plus tls_client_auth when trust anchors are configured) and tls_client_certificate_bound_access_tokens: true, PS256/ES256/EdDSA (also for request_object_signing_alg_values_supported and authorization_signing_alg_values_supported), dpop_signing_alg_values_supported PS256/ES256, code_challenge_methods_supported S256, response_types_supported code, and require_signed_request_object: true when the realm requires signed requests. RFC 8414, RFC 9126 §5, RFC 8705 §3.3, RFC 9101 §10.5

Message Signing (JAR and JARM)

FAPI 2.0 Message Signing (§5.3.1, §5.4.1) adds signed authorization requests and signed authorization responses. Both work in every realm; in a FAPI 2.0 realm the rules below are strict.

Signed request objects at PAR (JAR, RFC 9101 with RFC 9126 §3). The client sends client_id, its client authentication and request (the signed request object) to the PAR endpoint. The pushed request is then exactly the request object's parameters plus the client authentication: redirect_uri, state, nonce, scope, response_mode and every other parameter come from inside the object, and other form parameters are ignored. The client_id and iss inside must be the client's, and a request object that carries request_uri or request is refused. In a FAPI 2.0 realm the request object must:

  • be signed with PS256, ES256 or EdDSA (RS256 and none are refused) by a key of the client;
  • have aud equal to, or containing, the issuer;
  • have exp and nbf, with nbf at most 60 minutes in the past and exp at most 60 minutes after nbf; an nbf or iat up to 10 s in the future is accepted (clock skew), an expired object is refused;
  • have typ oauth-authz-req+jwt or JWT (or none).

A request object that fails a check is answered 400 invalid_request_object. When the client or its realm requires signed request objects, a pushed request without one is 400 invalid_request. The state and nonce of the response and the ID token are the ones inside the object; a state sent next to the request_uri at the authorization endpoint is ignored.

Signed authorization responses (JARM). A client asks for one with response_mode jwt, query.jwt, fragment.jwt or form_post.jwt (in its request object, its pushed request or a plain request), or registers a JARM response mode (client setting JARM response mode), in which case it always gets JARM. The response, success or error (also a cancelled sign-in, an expired request_uri or a refused plain request), is one JWT in the response parameter, with iss (the issuer), aud (the client), exp (2 minutes), iat and the response parameters (code, state or error, error_description). It is signed with the realm's ID-token key, with the client's authorization_signed_response_alg when it registered one (client setting Response signing algorithm; PS256, ES256 or EdDSA, and RS256 outside a FAPI 2.0 realm), else the realm's default (PS256, ES256 or EdDSA in a FAPI 2.0 realm). The registered algorithm must be one the realm has an active key for (in key custody: an active ID-token key that signs it); the admin API, Dynamic Client Registration and the realm import refuse it otherwise (admin API: 400, fieldErrorCodes.authorizationSignedResponseAlg = noActiveKey; registration: invalid_client_metadata). A response that cannot be signed as asked (the key was deactivated later) is a server error, never an unsigned response or one signed with another algorithm. Discovery lists the four JARM modes in response_modes_supported and the algorithms in authorization_signing_alg_values_supported. Encrypted responses (authorization_encrypted_response_alg) are not supported.

In every realm

Some of the work behind FAPI 2.0 applies to all realms because the specs require it:

  • Every authorization response carries iss (RFC 9207), in the query, the fragment or the form_post form; a JARM response carries it inside its JWT. Both discovery documents say authorization_response_iss_parameter_supported: true.
  • Client assertions and request objects are accepted with RS256, PS256, ES256 or EdDSA (before: RS256 only). Outside a FAPI 2.0 realm the token endpoint URL stays an accepted assertion audience (OpenID Connect Core §9).
  • An access token requested with a DPoP proof is bound to its key and typed DPoP, and userinfo accepts DPoP.
  • A request_uri inside a pushed authorization request is refused (RFC 9126 §2.1), and an expired request_uri is answered invalid_request_uri (RFC 9126 §2.2).
  • A request to the token, PAR, revocation or introspection endpoint without any client authentication (no assertion, a missing or unknown client_assertion_type) is 401 with the JSON error invalid_client (RFC 6749 §5.2), never a redirect to the sign-in page or an empty body.
  • The sign-in page has a Cancel while an authorization request is pending: the client gets error=access_denied with its state at its redirect URI (OpenID Connect Core §3.1.2.6). A pushed request is then used up.
  • A DPoP proof's htu is compared without query and fragment, after the RFC 3986 normalization (case of scheme and host, default port, dot segments, percent-encoding), at userinfo and wherever else a proof is checked (RFC 9449 §4.3).
  • A signed request object at the PAR endpoint and JARM by response_mode: see Message Signing.
  • A client with certificate-bound access tokens (RFC 8705) gets a token only with its certificate; without one the request is refused (invalid_request). Before, an unbound token was issued.
  • A certificate-bound token is accepted at userinfo and at the admin API only with the certificate it is bound to; a DPoP-bound token is never accepted as a Bearer token. Access tokens are never taken from the query string.
  • tls_client_auth and self_signed_tls_client_auth are available to every client (see below).

Mutual-TLS client authentication (RFC 8705 §2)

A client's token endpoint auth method can be:

  • tls_client_auth: a certificate issued by a CA in the server's trust anchors, with the subject the client registered: exactly one of the subject DN (subject_dn, compared as an X.500 name) or one subject alternative name, san_dns, san_uri, san_ip or san_email. Admin API and console: tlsClientAuthAttribute and tlsClientAuthValue; Dynamic Client Registration: tls_client_auth_subject_dn, tls_client_auth_san_dns, tls_client_auth_san_uri, tls_client_auth_san_ip or tls_client_auth_san_email (migration V198). The chain is checked with PKIX, validity dates included; revocation (CRL/OCSP) is not checked. Without trust anchors (helix.fapi.mtls.trust-anchors) the method is not advertised and refuses every certificate.
  • self_signed_tls_client_auth: the certificate is the first x5c certificate of a key in the client's JWK Set by value (jwks), and within its validity dates. A JWKS URL is not used for this method.

The client sends client_id and presents its certificate; nothing else. Certificate-bound access tokens are a separate client setting (x509CertificateBoundAccessTokens, RFC 8705 tls_client_certificate_bound_access_tokens), so an mTLS client can still use DPoP as its sender constraint. A wrong, untrusted or missing certificate is invalid_client.

TLS and mutual TLS (FAPI 2.0)

HelixIAM serves plain HTTP behind a TLS-terminating proxy or ingress. The transport rules of FAPI 2.0 (§5.2.2) are therefore set at that proxy, and HelixIAM relies on it for the parts below.

TLS policy. The proxy must offer TLS 1.2 and TLS 1.3 only (no TLS 1.0 or 1.1), and for TLS 1.2 only the cipher suites BCP 195 recommends (RFC 9325 §4.2):

  • TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
  • TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384

No DHE, CBC or static-RSA suites. TLS 1.3 with its standard suites (AES-GCM, CHACHA20-POLY1305) is fine, and a client that offers TLS 1.3 must get it (OpenSSL-based proxies do this by default). Every response carries Strict-Transport-Security: max-age=31536000; includeSubDomains. HelixIAM also sends that header on requests that arrived over HTTPS; a proxy that adds its own should drop the upstream copy so there is only one.

Client certificates. For tls_client_auth, self_signed_tls_client_auth and certificate-bound access tokens (RFC 8705), the proxy asks for a client certificate but does not require it and does not verify it: HelixIAM does. The proxy forwards the certificate as URL-encoded PEM in the header helix.fapi.client-cert-header (HELIX_FAPI_CLIENT_CERT_HEADER; empty by default, which reads no forwarded certificate at all; for example X-Client-Cert), and it must overwrite that header on every request, so a client can never send its own copy. A certificate is public: a header a client could set proves nothing.

HelixIAM honours the header only when the TCP peer is listed in helix.fapi.client-cert-trusted-proxies (HELIX_FAPI_CLIENT_CERT_TRUSTED_PROXIES): the proxies that request and forward client certificates. This list is separate from helix.security.trusted-proxies (HELIX_TRUSTED_PROXIES, for X-Forwarded-*) on purpose: a hop that is trusted for the forwarded host and scheme but does not terminate client TLS, such as the admin console's nginx, must never be able to present a certificate. Do not list such hops. From any other peer, or with the list empty, the header is ignored and an mTLS client fails closed: it cannot authenticate with its certificate, and no certificate-bound token is issued or accepted for it. Without the header and that list (and without server.ssl.client-auth want or need on the server itself) no certificate can reach HelixIAM, so discovery advertises no mutual-TLS method and no tls_client_certificate_bound_access_tokens, in every realm (RFC 8414 §2: only what the endpoint supports).

HelixIAM checks the certificate as follows:

  • tls_client_auth: the certificate must chain to a trust anchor in helix.fapi.mtls.trust-anchors (HELIX_FAPI_MTLS_TRUST_ANCHORS, the path of a PEM file of CA certificates; a path that cannot be read stops the server at startup), be meant for TLS client authentication (a key usage, when present, includes digitalSignature; an extended key usage, when present, includes clientAuth or anyExtendedKeyUsage, so a TLS server certificate of the same CA is refused), and match the subject the client registered. The proxy forwards the leaf certificate only, so put the issuing CA (an intermediate too, if it issues client certificates) in that file. The trust anchors are server-wide: every realm's tls_client_auth clients trust the same CAs, so a client of one realm can register a subject that a certificate issued for another realm's client carries. Give each tenant's clients subjects that only their CA issues, or use self_signed_tls_client_auth (exact certificate match) where tenants share a CA.
  • self_signed_tls_client_auth: the certificate must be the x5c certificate of a key in the client's JWK Set by value (exact match).
  • Certificate-bound tokens: the SHA-256 thumbprint of the certificate is put in the token (cnf.x5t#S256), and the same certificate must be presented with the token.

nginx example. TLS 1.2/1.3 with the BCP 195 suites, HSTS, an optional unverified client certificate, and the certificate forwarded in X-Client-Cert:

server {
  listen 443 ssl;
  server_name idp.example.com;
  ssl_certificate     /etc/nginx/tls/fullchain.pem;
  ssl_certificate_key /etc/nginx/tls/privkey.pem;

  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
  ssl_prefer_server_ciphers off;
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

  # Ask for a client certificate; HelixIAM verifies it.
  ssl_verify_client optional_no_ca;

  location / {
    proxy_pass http://helixiam:8080;
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-Host $http_host;
    proxy_set_header X-Forwarded-Port 443;
    # Replaces any X-Client-Cert the client sent; without a client certificate the header is not sent at all.
    proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
    proxy_hide_header Strict-Transport-Security;
  }
}

proxy_set_header with a header name replaces the incoming header of that name, and nginx does not pass a header whose value is empty, so a client-sent X-Client-Cert never reaches HelixIAM. Keep all proxy_set_header lines in the same block: a block that sets any of its own does not inherit those of the enclosing block. Then list the proxy's address in HELIX_TRUSTED_PROXIES and in HELIX_FAPI_CLIENT_CERT_TRUSTED_PROXIES.

Every other proxy that can reach HelixIAM must remove a client-sent copy of the header too. The admin console's nginx (the helix-dashboard image) clears the well-known client-certificate headers and HELIX_FAPI_CLIENT_CERT_HEADER in every location it proxies; the Helm chart passes it that name.

For Kubernetes with ingress-nginx, the Helm chart sets the mTLS annotations (ingress.mtls.enabled, forwarded header ssl-client-cert), and the TLS policy goes in the controller's ConfigMap: see the chart README (helm show readme), "TLS policy and mutual TLS (FAPI 2.0)".

A separate mTLS host. Some deployments ask for client certificates on a separate host only, so browsers on the main host never see a certificate prompt. Set helix.fapi.mtls-base-url (HELIX_FAPI_MTLS_BASE_URL) to that origin, for example https://mtls.idp.example.com (scheme and host only, no path). Discovery then publishes mtls_endpoint_aliases (RFC 8705 §5): the token, PAR, userinfo, introspection, revocation, device authorization and registration endpoints on that origin, with the same paths.

The issuer is derived from the host of the request. So the proxy of the mTLS host must send X-Forwarded-Host with the main (issuer) host, X-Forwarded-Port with its port and X-Forwarded-Proto: https; tokens issued through the aliases then carry the normal issuer. In the example above, for a second server block with server_name mtls.idp.example.com, that means proxy_set_header X-Forwarded-Host idp.example.com; instead of $http_host, and the main host's block can then leave out ssl_verify_client. The mTLS host's proxy must also be in HELIX_TRUSTED_PROXIES and HELIX_FAPI_CLIENT_CERT_TRUSTED_PROXIES.

Not supported yet

  • Encrypted request objects and responses (JWE request objects, authorization_encrypted_response_*).
  • Signed introspection responses (RFC 9701).
  • TLS policy inside HelixIAM. HelixIAM serves plain HTTP; TLS 1.2+ with the BCP 195 suites is set at the proxy or ingress (above). HelixIAM does not check the TLS version the proxy negotiated.
  • Certificate revocation (CRL, OCSP) for tls_client_auth and certificate-bound tokens. A revoked client certificate keeps authenticating until it expires: keep client certificates short-lived, and remove a compromised client's registration (or change its registered subject) instead. HelixIAM checks revocation for the EUDI wallet's issuer certificates only, not for client certificates yet.
  • Per-realm trust anchors for tls_client_auth: helix.fapi.mtls.trust-anchors is one set for the whole server (see above).
  • Dynamic Client Registration takes the RFC 8705 metadata, require_signed_request_object (RFC 9101) and authorization_signed_response_alg (JARM), but not a JARM response mode: that stays an administrator's setting (a registered client asks for JARM with response_mode). An RFC 7592 update may raise certificate-bound tokens, signed request objects and the client authentication method, never lower them (invalid_client_metadata): only an administrator does.