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.
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:
| File | Contents | Where it lives |
|---|---|---|
client.crt | Your 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.key | The 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:
| Material | Belongs to | Notes |
|---|---|---|
| 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) | You | You 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:
- Base64-decodes the header and parses the PEM leaf.
- Verifies the chain against the Apex Client CA pool (
ClientAuthkey usage). - Compares the SHA-256 fingerprint of the cert against your stored
cert_fingerprint. - Confirms the certificate CommonName matches the
client_idin 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.
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
| Situation | Result |
|---|---|
| Header missing / not base64 / not a PEM | 401 Unauthorized |
| Chain does not verify against the CA | 401 Unauthorized |
| Fingerprint does not match your client row | 401 Unauthorized |
CommonName ≠ client_id in the token | 401 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 withclient_id/client_secretonly. The operator flips this totruefrom the panel when you are ready.require_certificate = truebut 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.keyfrom the reveal modal into your secret manager — it is not recoverable later. - Send
X-Client-Cert(base64 PEM ofclient.crt) on every protected call, including the OAuth token request. - If the ingress is on
on, also present--cert/--keyfor the TLS handshake. - Track
cert_expires_atand request/regeneratebefore it lapses.