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-profilewith{"securityProfile": "FAPI2"}(or"STANDARD"). Needs realm-admin.GETon the same path returns the profile and the clients that would stop working (nonCompliantClients, each withreasons:publicClient,clientSecret,noKeys,implicit). - Realm export/import: the export's
keyCustody.securityProfilecarries the profile (andkeyCustody.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": truein the samePUTmakes 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
noneare refused) by a key of the client; - have
audequal to, or containing, the issuer; - have
expandnbf, withnbfat most 60 minutes in the past andexpat most 60 minutes afternbf; annbforiatup to 10 s in the future is accepted (clock skew), an expired object is refused; - have
typoauth-authz-req+jwtorJWT(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 theform_postform; a JARM response carries it inside its JWT. Both discovery documents sayauthorization_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_uriinside a pushed authorization request is refused (RFC 9126 §2.1), and an expiredrequest_uriis answeredinvalid_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) is401with the JSON errorinvalid_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_deniedwith itsstateat its redirect URI (OpenID Connect Core §3.1.2.6). A pushed request is then used up. - A DPoP proof's
htuis 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_authandself_signed_tls_client_authare 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_iporsan_email. Admin API and console:tlsClientAuthAttributeandtlsClientAuthValue; Dynamic Client Registration:tls_client_auth_subject_dn,tls_client_auth_san_dns,tls_client_auth_san_uri,tls_client_auth_san_iportls_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 firstx5ccertificate 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_SHA256TLS_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 inhelix.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, includesdigitalSignature; an extended key usage, when present, includesclientAuthoranyExtendedKeyUsage, 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'stls_client_authclients 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 useself_signed_tls_client_auth(exact certificate match) where tenants share a CA.self_signed_tls_client_auth: the certificate must be thex5ccertificate 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_authand 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-anchorsis one set for the whole server (see above). - Dynamic Client Registration takes the RFC 8705 metadata,
require_signed_request_object(RFC 9101) andauthorization_signed_response_alg(JARM), but not a JARM response mode: that stays an administrator's setting (a registered client asks for JARM withresponse_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.