Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Mutual TLS

A client can prove who it is with a TLS client certificate instead of a secret or a signed assertion, and its access tokens can be bound to that certificate so that a stolen token is useless without the certificate’s key. Both come from RFC 8705:

  • tls_client_auth: the client presents a certificate issued by a certificate authority the tenant trusts, carrying a subject registered for the client;
  • self_signed_tls_client_auth: the client presents a certificate it registered itself, in its JWK Set;
  • certificate-bound access tokens: every access token carries the SHA-256 thumbprint of the client’s certificate (cnf.x5t#S256), and a resource accepts it only over a connection with that certificate.

Machine-to-machine integrations, partner APIs and open-banking deployments use it where a shared secret is not good enough, and the FAPI 2.0 profile accepts it in place of private_key_jwt and DPoP.

Getting certificates to rIDM

A client certificate is part of the TLS handshake, so whatever terminates TLS has to ask for it. There are two ways, and either one turns the feature on (see Server configuration).

rIDM’s own mTLS listener

MTLS_BIND=0.0.0.0:8443
MTLS_CERT=/etc/ridm/tls/mtls-fullchain.pem   # else TLS_CERT
MTLS_KEY=/etc/ridm/tls/mtls-privkey.pem      # else TLS_KEY
MTLS_PUBLIC_URL=https://mtls.id.example.com:8443

A second listener serves the same routes and asks every connection for a client certificate without requiring one. It does not judge the chain (which CA, which certificate, is decided per client once the request names it), but the handshake does prove the client holds the certificate’s private key. Put it behind a layer-4 load balancer that passes TCP through, or expose it directly.

A reverse proxy that forwards the certificate

CLIENT_CERT_HEADER=X-Client-Cert
TRUSTED_PROXIES=10.0.0.0/8
MTLS_PUBLIC_URL=https://mtls.id.example.com

The proxy asks for the certificate (without verifying the chain: rIDM does that per client), then forwards it in CLIENT_CERT_HEADER. rIDM reads the header only from a TRUSTED_PROXIES peer, and the proxy must overwrite it on every request, so a caller can never supply its own. PEM, URL-encoded PEM and base64 DER are all accepted, leaf first. For example:

# nginx: a separate server block for the mTLS host.
server {
    listen 443 ssl;
    server_name mtls.id.example.com;
    ssl_certificate     /etc/nginx/tls/mtls-fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/mtls-privkey.pem;
    ssl_verify_client   optional_no_ca;   # ask, verify possession, leave the CA to rIDM

    location / {
        # Empty without a certificate, and nginx then drops the header: a
        # caller's own X-Client-Cert never reaches rIDM.
        proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://ridm;
    }
}
# Caddy
mtls.id.example.com {
	tls {
		client_auth {
			mode request
		}
	}
	reverse_proxy ridm:8080 {
		header_up X-Client-Cert {http.request.tls.client.certificate_der_base64}
	}
}
# Traefik (dynamic configuration). passTLSClientCert removes any
# X-Forwarded-Tls-Client-Cert the caller sent; set
# CLIENT_CERT_HEADER=X-Forwarded-Tls-Client-Cert.
http:
  routers:
    ridm-mtls:
      rule: "Host(`mtls.id.example.com`)"
      service: ridm
      middlewares: [client-cert]
      tls:
        options: mtls
  middlewares:
    client-cert:
      passTLSClientCert:
        pem: true
tls:
  options:
    mtls:
      clientAuth:
        clientAuthType: RequestClientCert

Kubernetes

The Helm chart’s mtls values cover both ways. Behind an ingress controller or gateway that asks for the client certificate without verifying its chain and forwards it, as the proxies above do, name its header:

trustedProxies: 10.0.0.0/8        # the controller's pod network
mtls:
  publicUrl: https://mtls.id.example.com
  clientCertHeader: X-Client-Cert

For rIDM’s own listener, the chart mounts a kubernetes.io/tls Secret, sets MTLS_BIND=0.0.0.0:8443 with its certificate, and adds a second Service (<release>-mtls) for it. That Service has to be reached at layer 4 (a LoadBalancer, or an ingress controller’s TLS passthrough): an ingress that terminates TLS would take the client certificate with it.

mtls:
  publicUrl: https://mtls.id.example.com
  listener:
    enabled: true
    tlsSecret: ridm-mtls-tls
    service:
      type: LoadBalancer
      annotations:
        service.beta.kubernetes.io/aws-load-balancer-type: nlb

The chart refuses publicUrl without either, a listener without its Secret, and a header without trustedProxies.

Endpoint aliases

Asking for a certificate on the host users sign in on makes browsers with a certificate installed show a picker on the login page. RFC 8705 §5 solves this with a second host: with MTLS_PUBLIC_URL set, discovery lists mtls_endpoint_aliases for the endpoints a client authenticates at (token, PAR, introspection, revocation, userinfo, device authorization and CIBA), under {MTLS_PUBLIC_URL}/t/{slug}. Browsers keep using the ordinary endpoints; mTLS clients use the aliases. A private_key_jwt assertion sent to the alias token endpoint may name it as its aud, and a DPoP proof may name the alias URL as its htu.

Trusting certificate authorities

tls_client_auth clients need at least one of the tenant’s certificate authorities: Security → Client certificates in the console, or the admin API:

curl -X POST "$RIDM/admin/tenants/acme/mtls/trust-anchors" \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d "{\"name\": \"Partner CA\", \"certificate_pem\": $(jq -Rs . < partner-ca.pem)}"

Each entry is one CA certificate: a root, or an intermediate on its own (a client’s chain only has to reach one of them). rIDM refuses a certificate that is not a CA or has expired, and lists each with its subject, expiry and SHA-256 thumbprint. A client certificate passes when it chains to one of them through the intermediates the client sent, is valid now, and, when it has an extended key usage, allows client authentication. A tenant trusts at most 50 authorities; they travel in the tenant document as mtls_trust_anchors. Adding and removing one is audited as mtls_trust_anchor.created and .deleted.

rIDM does not check revocation (CRLs or OCSP). To cut off a certificate, remove its authority, or change the client’s registered subject; keep client certificates short-lived.

Registering a client

In the console, pick Mutual TLS (CA-issued certificate) or Mutual TLS (self-signed certificate) as the client’s authentication; the creation wizard leaves both to the detail page, where their extra settings are.

tls_client_auth needs exactly one of these, naming what the certificate must carry:

FieldMatched against
tls_client_auth_subject_dnthe subject, as an RFC 4514 string with the most specific name first (CN=billing,O=Acme,C=US), attribute by attribute; values compare without regard to case or repeated spaces. openssl x509 -noout -subject -nameopt RFC2253 prints it in this form.
tls_client_auth_san_dnsa DNS name in the subject alternative names, without regard to case
tls_client_auth_san_uria URI SAN, exactly (a SPIFFE ID, for instance)
tls_client_auth_san_ipan IP address SAN
tls_client_auth_san_emailan email SAN, without regard to case

self_signed_tls_client_auth needs the certificate in the client’s JWK Set: the first entry of a key’s x5c, inline in jwks or served at jwks_uri (re-fetched once when an unknown certificate arrives, like a key for private_key_jwt). No chain is checked; the certificate itself is the registration.

At the token endpoint (and PAR, introspection, revocation, device authorization and CIBA) the client sends client_id in the body and nothing else; the certificate is the credential:

curl --cert billing.pem --key billing.key \
  https://mtls.id.example.com/t/acme/token \
  -d grant_type=client_credentials -d client_id=billing

A missing, untrusted or mismatched certificate is invalid_client. Both methods are also accepted by dynamic registration, with the metadata names above.

Certificate-bound tokens

Switch on Certificate-bound access tokens (tls_client_certificate_bound_access_tokens) and the client’s token requests must come over mutual TLS; every access token then carries

"cnf": { "x5t#S256": "base64url(SHA-256(the certificate's DER))" }

This works whatever the client authenticates with, a secret included. The token type stays Bearer: the binding is checked on the connection, not in a header. For a public client, its refresh tokens are bound to the certificate too; a confidential client’s are bound by its credentials, and each refresh binds the new access token to the certificate of that request, so certificates can be rolled.

rIDM checks the binding wherever it accepts an access token (userinfo, the account and admin APIs), and token exchange refuses a certificate-bound subject token unless the request carries the same certificate and the exchanging client is itself registered for bound tokens, so the result is bound again.

A resource server built on the ridm-auth crate checks the binding with Validator::validate_with_certificate, handing it the connection’s DER certificate; validate alone refuses a bound token.

FAPI 2.0

A FAPI 2.0 client may authenticate with tls_client_auth or self_signed_tls_client_auth instead of private_key_jwt, and be sender-constrained by its certificate instead of DPoP: a FAPI client registered for certificate-bound tokens does not need DPoP proofs. It needs at least one of the two.