Upgrade — Server Key Pinning#
With mutual-TLS server pinning — ClientAuthenticationMethodMTLS on iOS, ClientAuthenticationMethodConfiguration.MtlsKeyHash on Android — the SDKs used to accept the identity server as soon as the pinned certificate or public key matched any certificate the server presented, without validating the chain. A network attacker could terminate TLS with their own certificate, append the genuine identity-server certificate — public information — and be accepted. Both SDKs now validate the presented chain with the platform first, and only then compares the pin against a certificate of that validated chain.
Each section below has a tab per platform: iOS covers ClientAuthenticationMethodMTLS, Android covers ClientAuthenticationMethodConfiguration.MtlsKeyHash and Mtls.
What Changed#
| Situation | Before | After |
|---|---|---|
| A forged certificate served with a copy of the pinned certificate | Accepted: the pin matched somewhere in the chain | Refused. The chain fails platform validation before any pin is compared |
| A self-signed or private-CA identity server, pinned | Accepted | Refused until its root certificate is installed on the device |
| Which certificate the pin may match | Any certificate the server sent | Any certificate of the validated chain — the leaf, an intermediate or the root |
isValidatingHostname: true | The certificate’s common name had to equal the host; wildcard and SAN-only certificates failed | The platform’s Subject Alternative Name check, wildcards included |
isValidatingHostname: false (certificate pinning with serverPEMFilename; key pinning always validates the hostname) | No check at all | Skips only the hostname match; the chain is still validated, only a pin on the server’s own certificate is accepted, and a warning is logged at configuration |
An http:// endpoint with mutual TLS configured | The request was sent in clear, without client authentication or pinning | Refused before anything is sent, with the error code insecure_transport; building a HaapiTokenManager with a non-https token endpoint fails with the same code. Creating an accessor or a HaapiManager with an http address fails earlier, with insecure_haapi_configuration, unless allowInsecureHttpEndpoints permits it — see HAAPI Request Target Hardening |
KeyPinning.hostname | Matched the connection host exactly | Matched without regard to letter case |
| RSA-3072 and P-521 server keys | Could not be pinned | Pinned like the other supported sizes |
| Expiring pinned certificate | Silent | A warning when expiry is within 30, 14 and then 7 days, once per threshold per app run |
| One pin, or no pin, for a host | Silent | A warning for a single pin; an error for an empty pin list, since every connection is then refused |
ClientAuthenticationMethodConfiguration.MtlsKeyHash pins the public keys of the Curity Identity Server‘s certificates. Its trust decision changed: a server whose certificate authority the device does not trust is now refused. An app trusts only the system certificate authorities unless its Network Security Config says otherwise, so add the authority to the app’s Network Security Config <trust-anchors>, or, in debug builds that trust the user store, install it on the device; or use ClientAuthenticationMethodConfiguration.Mtls with a trust store instead.
The platform now validates the server’s certificate chain first, exactly as it does for any HTTPS connection: the chain must lead to a certificate authority the device trusts, every certificate below the trust anchor must be within its validity period, and the app’s Network Security Config applies. The connection is then trusted only if a pinned key matches one of the certificates in that validated chain — the leaf, an intermediate, or the trust root the platform used, even when the server did not send that root. Pinning tightens platform trust; it never replaces it.
Previously, a pinned key’s hash was compared against every certificate in the presented chain, and any match was accepted, with no validation of the chain at all. A server that presented a forged leaf certificate alongside a copy of the pinned certificate authority was accepted, and so was a self-signed or private-authority server the device had no reason to trust. Both are now refused by the platform validation, regardless of the pin.
hostname on each KeyPinning is the bare host the SDK connects to — idsvr.example.com, not idsvr.example.com:8443 or https://idsvr.example.com — compared without regard to case. Setting isValidatingHostname to false on MtlsKeyHash only turns off per-host pin scoping: hostname is then ignored for pin selection, and any pinned key is accepted on a connection to any host. It does not skip the platform’s chain validation, and it does not disable the platform’s own hostname verification, which runs separately either way.
Two related corrections shipped alongside the fix: an expired certificate below the trust anchor is now refused by the platform validation instead of accepted, and MtlsKeyHash and Mtls both fail with a typed error, before anything is sent, instead of proceeding without client authentication when the endpoint is not HTTPS.
If you read the Applicability section below and conclude this upgrade does not affect you, read the Rotation Playbook anyway. It applies to every MtlsKeyHash configuration regardless of this upgrade, and it is what your next certificate or key rotation will actually exercise.
| Situation | Before | After |
|---|---|---|
Leaf pin, chain trusted by the device, hostname equals the connection host | works | works |
| Self-signed or private-authority server whose root the app does not trust | accepted | refused — add the root to the app’s Network Security Config <trust-anchors>, or, in debug builds that trust the user store, install it on the device; or use Mtls with a trust store |
Leaf pin, hostname equals the certificate’s common name, which differs from the connection host | works | works, with a warning logged, provided no pin names the connection host; removed in the next major version |
| Pin on an intermediate or root key, chain trusted by the device | works | works; the pin may match any certificate of the validated chain, including a root the server did not send |
| Pin on an intermediate or root key, forged leaf served with a copied certificate authority | accepted | refused by the platform validation |
| Any pin, expired certificate below the trust anchor | accepted | refused by the platform validation |
| Pin on an installed trust anchor that has expired | accepted | accepted, with a warning logged — the platform does not date-check an installed trust anchor |
isValidatingHostname = false | works | works — pin selection only; the platform validation still applies |
http:// endpoint with Mtls or MtlsKeyHash | proceeds without client authentication | refused before anything is sent, error code insecure_transport; creating an accessor or a HaapiManager with an http address fails earlier, with insecure_haapi_configuration, unless allowInsecureHttpEndpoints permits it |
Pinning now only ever tightens the platform’s trust decision; it never replaces it. The public initializers and isValidatingHostname keep their signatures, so no code change is needed to upgrade, but a deployment can start failing for one of the reasons below.
Before You Upgrade#
-
Check the identity server’s certificate chain. It must chain to a root the device trusts: a public CA, or a private root installed on the device through device management. A self-signed certificate or an uninstalled private CA is refused even when pinned.
-
Check what you pinned. A pin on the leaf, an intermediate or the root keeps working as long as that certificate is in the validated chain. If you only pinned the leaf, consider pinning the intermediate as well, or configure two pins per host — the current key and the next — so a rotation does not break the app.
-
Check every endpoint is
https.HaapiConfiguration’s base URL, the token endpoint and the registration endpoint. Anhttp://URL is now refused with mutual TLS configured. -
Check the certificate names the host. With
isValidatingHostname: true(and always with key pinning) the certificate must carry the host in its Subject Alternative Name; a common name alone is not enough on iOS. Keepfalseonly for a certificate that cannot name the host, and expect the warning. -
Local development. The identity server’s default self-signed certificate is refused. Issue it a certificate with a Subject Alternative Name from a development CA, and install that CA on the simulator:
xcrun simctl keychain <device> add-root-cert dev-ca.pem
App Transport Security is unchanged. ATS already required an app-level exception to accept a server the system does not trust; the SDK now never accepts such a server on its own, with or without that exception.
- Check the identity server’s certificate chain. It must chain to a certificate authority the app trusts: a system CA, or a private root added to the app’s Network Security Config
<trust-anchors>(or, in debug builds that trust the user store, installed on the device). A self-signed certificate or an untrusted private CA is refused even when pinned; alternatively, useMtlswith a trust store that holds the root. - Check what you pinned. A pin on the leaf, an intermediate or the root keeps working as long as that certificate is in the validated chain, including a root the server does not send. Configure two pins per host — the current key and the next — so a rotation does not break the app.
- Check every endpoint is
https. WithMtlsorMtlsKeyHash, anhttp://endpoint now fails withinsecure_transportbefore anything is sent, instead of sending the request without client authentication. Creating an accessor or aHaapiManagerrefuses anhttpaddress even earlier, withinsecure_haapi_configuration, unlessallowInsecureHttpEndpointspermits it — see HAAPI Request Target Hardening. - Check
hostnamenames the connection host. EachKeyPinning.hostnameis the bare host the SDK connects to; a certificate’s common name still works, with a warning, until the next major version. - Local development. The identity server’s default self-signed certificate is refused. Add the development CA, or the self-signed certificate itself, to the debug build’s Network Security Config
<trust-anchors>.
Applicability#
| Surface | Affected |
|---|---|
Driver Layer, Android — ClientAuthenticationMethodConfiguration.MtlsKeyHash | Yes — the platform validation, expiry, and hostname-matching changes above, and the http:// failure below |
Driver Layer, Android — ClientAuthenticationMethodConfiguration.Mtls (truststore, no key pinning) | Partially — unaffected by the trust change, but now fails with insecure_transport on an http:// endpoint, exactly like MtlsKeyHash |
| SDK Layer and UI Widget, Android | Yes, inherited — neither has an API change of its own |
React Native on Android — AndroidMtlsKeyPinningAuthentication() | Yes — the bridge maps directly onto MtlsKeyHash |
| React Native on iOS, and iOS natively | Yes — see the iOS tabs of this page |
Any client not using Mtls or MtlsKeyHash (Secret, Signed JWT, no authentication) | No |
Computing a Pin#
A pin is the base64 SHA-256 of the certificate’s DER SubjectPublicKeyInfo. The same string works for the Android SDK.
openssl x509 -in server.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64
Run the command above on the leaf certificate’s PEM unless you are deliberately pinning an issuing certificate authority.
To see the chain the server actually presents, connect with s_client and inspect the certificates it prints:
openssl s_client -showcerts -connect idsvr.example.com:443 </dev/nullThe first certificate printed is the leaf; the certificates after it are the certificate authorities the server sent with the handshake. The key you pin may belong to any of them, or to the root certificate authority the platform used to validate the chain, even if the server does not send that root — compute the root’s hash from its PEM: the certificate you added to the app’s Network Security Config <trust-anchors> (or, in debug builds that trust the user store, installed on the device), or the published certificate of the system authority. Pin the leaf for the tightest possible pin, or a certificate authority higher in the chain if you want the pin to survive a leaf rotation.
Handling the Refusals#
A request to a non-https URL, or a HaapiTokenManager build with a non-https token endpoint, reports the error code insecure_transport. Creating an accessor or a HaapiManager with an http address is refused earlier, with insecure_haapi_configuration, unless allowInsecureHttpEndpoints permits it — see HAAPI Request Target Hardening. The insecure_transport cause carries the refused URL’s scheme and host as properties, never its path or query; the cause’s description names only the scheme, so it can be shown to a user:
if case .error(let error) = result,
let idsvrError = error as? IdsvrError,
idsvrError.error == HaapiErrorCode.insecureTransport.rawValue
{
let refusal = idsvrError.cause as? HaapiInsecureTransportRefusal
// refusal?.scheme, refusal?.host
}Its recovery suggestion is .unrecoverable(action: .introspectCause): fix the configured URL, do not retry.
A refused server certificate fails the request the way a cancelled trust challenge always has — with the platform’s cancellation error, URLError.cancelled — and logs one line under the Driver’s flow log tag naming the reason: the chain failed system validation (with the platform’s reason), no pin is configured for the host, no certificate of the validated chain matched, the server key type is not supported for pinning, or the challenge carried no server trust. The host in that line goes through the logger’s sensitive-value masking.
Some of the rows below need a fix on your side; others are server misconfigurations to correct on the Curity Identity Server. Each row names the symptom you would see after upgrading.
| Situation | Symptom after upgrading | Action |
|---|---|---|
| A leaf pin already names the host you connect to | none | nothing to do |
hostname holds the certificate’s common name rather than the connection host | still works, with a warning logged, as long as no pin in the configuration names the connection host | change hostname to the host the SDK connects to; the common-name fallback is removed in the next major version. Note that it does not apply on a connection to a host that has pins of its own — a mixed configuration, where some entries name hosts and others name common names, can fail for the hosts that are pinned |
| The server’s root certificate authority is not trusted by the device — including a self-signed server certificate | SSLHandshakeException caused by a CertificateException: “MTLS failed because the platform does not trust the server certificate chain.” | an app trusts only the system certificate authorities unless its Network Security Config says otherwise, so add the root to the app’s Network Security Config <trust-anchors>, or, in debug builds that trust the user store, install it on the device; or switch to Mtls with a trust store that holds the root |
| No certificate in the validated chain carries a pinned key | SSLHandshakeException caused by a CertificateException: “MTLS failed because NO certificate found with a public key that matches any of the provided server key pinnings.” | recompute the hash from a certificate the server actually uses, with openssl s_client -showcerts (see Computing a Pin), and check that hostname names the host you connect to |
| A certificate below the trust anchor has expired | SSLHandshakeException caused by a CertificateException: “MTLS failed because the platform does not trust the server certificate chain.” — the platform refuses the expired certificate | renew the certificate; reuse the same key so the existing pin keeps working |
The endpoint is configured as http:// | refused before anything is sent: an IdsvrHaapiException.Unrecoverable whose error is insecure_transport (DriverErrorCodes.INSECURE_TRANSPORT). Its message names no URL, so it can be shown to a user; the refused URL is logged at ERROR under HAAPI_DRIVER_HTTP. Creating an accessor or a HaapiManager refuses an http address earlier, with insecure_haapi_configuration, unless allowInsecureHttpEndpoints permits it | change the endpoint to https:// |
Every refusal by the platform validation — an untrusted authority, an expired certificate, a forged chain — surfaces as the same “does not trust the server certificate chain” message. The platform’s own reason is the CertificateException’s cause, and HaapiLogger logs it at ERROR as “The platform does not trust the server certificate chain:” followed by that reason.
Rotation Playbook#
Read this even if the upgrade itself changes nothing for you: a pin outlives the release that set it, and the next certificate or key rotation on the server is what actually exercises this code path.
| Event | App release needed? | What to do |
|---|---|---|
| Certificate renewed, same key | No | Nothing — the pin follows the key, not the certificate |
| Certificate expired | No | The operator renews it, reusing the same key |
| Server changes its key | Yes, before the rotation | Ship a release that pins the current key and the next key, wait for adoption, rotate the server, then drop the retired pin in a later release |
A single pin has no rotation path that does not break every installed app the moment the server’s key changes — both SDKs log a configuration-time warning when a host has only one pin, for exactly this reason.
let clientAuthPinned = try ClientAuthenticationMethodMTLS(
pkcs12Filename: "client.p12",
pkcs12Passphrase: "passphrase",
serverKeyPinnings: [
// The key currently in use.
KeyPinning(
hostname: "idsvr.example.com",
publicKeyHash: "Kjuy4mT3fbeDozRNP6rTjWRYmbs79Begb5Roq+DUu7s="
),
// The key the server will rotate to next.
KeyPinning(
hostname: "idsvr.example.com",
publicKeyHash: "L1sN0kkq8t1BR8nGZ+58vN5FQ4URtT/nRhrEUUwj9ZQ="
)
],
bundle: Bundle.main
)An empty serverKeyPinnings list logs an error, since every connection is then refused.
val mtlsPinned = ClientAuthenticationMethodConfiguration.MtlsKeyHash(
clientKeyStore = myClientKeyStore,
clientKeyStorePassword = myClientKeyStorePassword,
serverKeyPinnings = setOf(
// The key currently in use.
ClientAuthenticationMethodConfiguration.MtlsKeyHash.KeyPinning(
hostname = "idsvr.example.com",
publicKeyHash = "Kjuy4mT3fbeDozRNP6rTjWRYmbs79Begb5Roq+CNwiG="
),
// The key the server will rotate to next.
ClientAuthenticationMethodConfiguration.MtlsKeyHash.KeyPinning(
hostname = "idsvr.example.com",
publicKeyHash = "L1sN0kkq8t1BR8nGZ+58vN5FQ4URtT/nRhrEUUwj9ZQ="
)
)
)serverKeyPinnings is a Set precisely so a configuration can carry both keys during a rotation. An empty set throws IllegalArgumentException at construction.
Ship the release with both pins well before the server rotates, wait until enough of the installed base has updated, then rotate the server’s key. Only after that adoption window closes should a later release drop the retired pin — dropping it early leaves users on the old release unable to connect, and keeping it forever is harmless but unnecessary once no installed app relies on it.
Seeing the Warnings#
The expiry countdown and the single-pin warning are logged at warning level under the Driver’s flow log tag; a refused server certificate is logged at error level under flow, and a refused plain-http request under http. Warnings are enabled by default, but no log tag is, so nothing is logged until you set the tags — before any HAAPI object is created:
HaapiLogger.followUpTags = DriverFollowUpTag.allCasesThe countdown names the host and is logged once per pinned key and threshold per app run, at the smallest threshold crossed: The pinned server certificate for <host> expires within 30 days; pin the next key before it is rotated. To get the countdown as an early alarm, forward warnings to your monitoring.
Two settings gate every
HaapiLoggerline, and both warnings below are logged atWARN. A release build defaults toenabled = falseand levelERROR, so setting only one of the two produces no output:HaapiLogger.enabled = true HaapiLogger.setLevel(HaapiLogger.LogLevel.WARN)With both set, you see the certificate-expiry countdown (logged once at 30 days, again at 14, and again at 7, naming the host and the expiry instant; a pinned installed trust anchor that has already expired, which the platform still accepts, is reported with how many days ago it expired) and the single-pin configuration warning. To get the countdown as an early alarm, forward
HaapiLoggeroutput atWARNand above to your monitoring. See Driver Layer Logging for the full configuration.Set both before you construct the configuration, not just before you connect. The single-pin warning is logged from
MtlsKeyHash’s owninitblock, at construction time — enabling logging afterward means you never see it, no matter how correctly the two lines above are set. The expiry countdown does not share this trap: it re-checksshouldLogon every handshake before deciding whether it already logged that threshold, so enabling logging later still surfaces it on the next connection.The rotation playbook, not the logging, is what protects a deployment. A missed warning is a missed reminder; an unplanned key rotation with a single pin is an outage.