# 6. mTLS: in the mesh or in the application *Prev: [5. The gateway](05-the-gateway.md) · Next: [7. Choosing](07-choosing.md)* Run `./scripts/certs.sh`, then start `MtlsApplication` on 8443. The certificates it generates are the point: `edge.crt` and `rogue.crt` have **identical subjects** and different issuers. ``` subject=CN = edge-service, OU = payments issuer=CN = Internal Mesh CA subject=CN = edge-service, OU = payments issuer=CN = Some Other CA ``` Identity under mTLS is not the subject. It is the subject plus the fact that a CA in the trust store vouched for it. ## In-application mTLS ```yaml server: ssl: bundle: server client-auth: need spring: ssl: bundle: pem: server: keystore: { certificate: "file:...server.crt", private-key: "file:...server.key" } truststore: { certificate: "file:...internal-ca.crt" } ``` ```java http.x509((x509) -> x509 .subjectPrincipalRegex("CN=([^,]*)(?:,|$)") .userDetailsService(certificateUsers())); ``` A good certificate produces a `PreAuthenticatedAuthenticationToken` with the CN as the principal, and — in Spring Security 7 — a `FACTOR_X509` authority alongside the roles, the sibling of the `FACTOR_BEARER` you get from a JWT and the `FACTOR_PASSWORD` you get from Basic. Any assertion using `containsExactly` on authorities will fail on it. ## Three things the transcript shows that a diagram does not [`docs/output/06-mtls.txt`](output/06-mtls.txt): **The rogue certificate produces no HTTP status at all.** `curl` exits 56; there is no response line, no 401, no 403, and nothing in the application log at `INFO`. The handshake failed. Your application-level metrics will show nothing, because from the application's point of view nothing happened. Debugging this means `-Djavax.net.debug=ssl:handshake` or the load balancer's own logs. **`client-auth: need` is a property of the connector, not of a path.** `/mtls/trusted-header` is `permitAll()` and it fails exactly the same way without a certificate. You cannot expose a public health endpoint on an mTLS-only connector; it needs a second connector, or `want` instead of `need` plus an explicit authorization rule that treats an absent certificate as anonymous. **Header-based identity is verified by nothing.** The last call in the transcript presents a valid certificate for `edge-service` and a header claiming to be `payments-service`, and the endpoint reports `payments-service`. ## Mesh mTLS A service mesh terminates TLS in a sidecar. The application receives plain HTTP on localhost and the peer identity arrives as a header — `X-Forwarded-Client-Cert` in Envoy, carrying the SPIFFE URI SAN. The trade: | | Mesh | In-application | |---|---|---| | Certificate lifecycle | Handled, rotated automatically | Yours: issuance, rotation, expiry alerts | | Application code | None | An SSL bundle plus `x509(..)` | | Identity in the app | A header | A verified `X509Certificate` | | Works with cert-bound tokens (RFC 8705) | **No** | Yes | | Failure mode | Anything that bypasses the sidecar can spoof the header | Handshake failure, no HTTP status | The row that decides it for a security-sensitive service is the RFC 8705 one. Certificate-bound access tokens — the `cnf` / `x5t#S256` claim, which `X509CertificateThumbprintValidator` already checks by default (chapter 4) — bind a token to the TLS connection it was issued for, so a stolen token is useless without the private key. That binding requires the application to see the client certificate. Terminate mTLS in a sidecar and the strongest defence available against token theft is off the table. If you take mesh identity from a header, the header must be **stripped at ingress** on every path into the pod, and the pod must not be reachable except through the proxy. Both are infrastructure guarantees that no amount of application code can verify — which is exactly why `/mtls/trusted-header` answers `"verifiedBy": "nothing. This endpoint believes a header."`. --- *Prev: [5. The gateway](05-the-gateway.md) · Next: [7. Choosing](07-choosing.md)*