Skip to main content

mTLS Client Certificates

When your client has require_certificate = true, every protected call must carry your client certificate in addition to the OAuth bearer token. This page explains where the certificate and key live, how to present them on the wire, and what happens when verification fails.

Who issues the certificate?

You never generate the certificate yourself. The platform operator mints it for you in the admin / owner panel at create or regenerate time, signed by the internal Apex Client CA. The server stores only the public certificate and its SHA-256 fingerprint — the private key is shown once and is never retrievable again. Treat the reveal modal output as a one-time secret.

What you receive

At create / regenerate time you get a bundle alongside your client_id / client_secret:

FileContentsWhere it lives
client.crtYour leaf certificate (PEM CERTIFICATE block). CommonName = your client_id.Store it with your client; it is also recoverable from the server's certificate field.
client.keyThe matching private key (PEM PRIVATE KEY block).Your secret manager only. Never sent to the server, never recoverable after the modal closes.

The certificate defaults to a 1-year lifetime. Rotate before cert_expires_at by asking the operator to call /regenerate — this mints a fresh keypair and invalidates the old fingerprint.

Where the certificate goes on each side

This is the part that is easy to get wrong. There are three distinct pieces of material and they do not all go to the same place:

MaterialBelongs toNotes
CA cert + CA key (signer)Apex platform (admin / owner service)Never leaves the platform. Used to sign your client.crt.
CA cert only (verifier)Apex platform (integration service)Used to verify the certificate you present. No private key.
client.crt + client.key (leaf)YouYou present client.crt on every request; client.key stays in your secret store and proves you own the cert during the TLS handshake.

So on your side you only ever hold client.crt + client.key. You do not receive or need any CA private key.

Presenting the certificate on the wire

Verification happens in two layers — both can be active at once:

Layer 1 — NGINX TLS handshake (defense-in-depth)

The public ingress runs auth-tls-verify-client. Present the cert as a normal mTLS client certificate during the TLS handshake:

curl --cert client.crt --key client.key \
-H "Authorization: Bearer $ACCESS_TOKEN" \
https://integration-api.tokenizationtr.com/v1/health

When the ingress is set to optional this layer verifies the chain only if a cert is presented; when set to on it refuses any non-mTLS handshake before the request even reaches the application.

Layer 2 — X-Client-Cert header (authoritative)

The application performs its own check, independent of any proxy, by reading the X-Client-Cert header. Its value is the base64-encoded PEM of your leaf certificate. This is the check that actually binds the request to your client row, so it must be present whenever require_certificate = true:

# Base64-encode the whole PEM (no line wrapping)
CLIENT_CERT_B64=$(base64 -w0 client.crt) # macOS: base64 -i client.crt | tr -d '\n'

curl -H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-Client-Cert: $CLIENT_CERT_B64" \
https://integration-api.tokenizationtr.com/v1/...

The application then:

  1. Base64-decodes the header and parses the PEM leaf.
  2. Verifies the chain against the Apex Client CA pool (ClientAuth key usage).
  3. Compares the SHA-256 fingerprint of the cert against your stored cert_fingerprint.
  4. Confirms the certificate CommonName matches the client_id in your token.

All four must pass. The same enforcement runs on the OAuth token endpoint (/v2/oauth/token) and on the MCP bearer flow, so a cert-required client must present X-Client-Cert even when fetching its token.

tip

Layer 2 is the source of truth. Even if the ingress is on optional and lets a cert-less TLS handshake through, the application still rejects the request unless a valid X-Client-Cert header is present for a require_certificate = true client.

Failure responses

SituationResult
Header missing / not base64 / not a PEM401 Unauthorized
Chain does not verify against the CA401 Unauthorized
Fingerprint does not match your client row401 Unauthorized
CommonName ≠ client_id in the token401 Unauthorized
Cert valid but client opted out (require_certificate = false)Allowed (check skipped)

mTLS failures are 401, distinct from the IP-allowlist guard which returns 403. A 401 with a valid bearer token almost always means a certificate problem.

Migration behaviour

Two transition states are intentionally allowed so partners can adopt mTLS gradually:

  • require_certificate = false (pre-existing clients) — the certificate check is skipped entirely. You operate with client_id / client_secret only. The operator flips this to true from the panel when you are ready.
  • require_certificate = true but no fingerprint stored yet — a CA-verified certificate is required, but the fingerprint equality check is skipped until the operator regenerates the client. The next regenerate moves you onto the strict path.

Checklist

  • Save client.key from the reveal modal into your secret manager — it is not recoverable later.
  • Send X-Client-Cert (base64 PEM of client.crt) on every protected call, including the OAuth token request.
  • If the ingress is on on, also present --cert/--key for the TLS handshake.
  • Track cert_expires_at and request /regenerate before it lapses.