The Dangerous Gap Between mTLS and Application Security
Implementing strict client certificate validation in Quarkus without polluting your REST layer
The incident started like they always do: a partner system “successfully” connected, our service returned 200, and the business team celebrated a green dashboard. Two hours later, a second partner called and claimed they could impersonate the first by replaying the same client certificate chain through a different gateway. The TLS handshake succeeded because the chain was technically trusted, but our system never enforced the domain rules that made those certificates meaningful. Debugging was painful because everything looked correct at the transport layer, and the only thing that was wrong lived in the gray zone between “cryptographically valid” and “allowed in this ecosystem.”
That’s why I liked gematik’s write-up so much. They didn’t stop at “enable mTLS.” They showed how to fail the handshake when the client certificate doesn’t match the specification, and they published the idea openly so others can build on it. That open source reflex matters, especially in infrastructure security where everybody benefits when the hard parts are documented and shared.
In this tutorial we’ll implement the same intent in Quarkus: validate the presented client certificate against strict rules and deny access if it doesn’t comply. Quarkus won’t force us into Tomcat customizers or Spring-specific hooks, but it does change where the enforcement point lives. We’ll let Quarkus enforce “client must present a cert” at the TLS layer, then we’ll enforce “cert must satisfy our rules” at the authentication layer, so every protected endpoint behaves like the handshake should have failed.
If you’ve ever tried to explain to a security auditor why “the cert was trusted” was not the same as “the request was allowed,” you already know why this matters.
Build the Quarkus Project Like You Mean It
You’ll need Java 21, Maven, and either OpenSSL or keytool available on your path.
One bootstrap command or a simple clone of my Github repository:
quarkus create app dev.mainthread:mtls-client-cert-guard \
--java=21 \
--no-code \
--extensions=quarkus-rest-jackson,quarkus-security,quarkus-hibernate-orm-panache,quarkus-jdbc-postgresql,smallrye-openapi
cd mtls-client-cert-guardQuarkus extensions we’ll use (and why):
quarkus-rest-jacksonso we can return structured JSON without ceremony.quarkus-securitybecause we’ll enforce certificate rules as an authentication mechanism, not as an ad-hoc filter.quarkus-hibernate-orm-panache+quarkus-jdbc-postgresqlso we can audit accepted client certificates with a real transaction boundary.smallrye-openapiso the endpoint surface stays self-documented while we iterate.
Mint a Local CA, Server Cert, and Client Cert
We need a CA that signs both the server certificate and the client certificate, because mTLS is not about “any cert,” it’s about “a cert that chains to a trust anchor you chose.”
These commands generate PKCS12 stores that Quarkus can load directly.
mkdir -p src/main/resources/certs
cd src/main/resources/certs
# 1) Root CA
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-subj "/CN=MainThread Demo CA" \
-out ca.crt
# 2) Server cert
openssl genrsa -out server.key 2048
openssl req -new -key server.key -subj "/CN=localhost" -out server.csr
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out server.crt -days 825 -sha256
# 3) Client cert (the one we will validate)
openssl genrsa -out client.key 2048
openssl req -new -key client.key -subj "/CN=partner-a/O=Example Partner" -out client.csr
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out client.crt -days 825 -sha256
# 4) Convert to PKCS12 for Quarkus server keystore
openssl pkcs12 -export -out server-keystore.p12 \
-inkey server.key -in server.crt -certfile ca.crt \
-passout pass:changeit
# 5) Truststore contains the CA that we accept for client authentication
keytool -importcert -noprompt \
-alias demo-ca -file ca.crt \
-keystore server-truststore.p12 -storetype PKCS12 \
-storepass changeitWire Up mTLS in application.properties
This is where Quarkus does what Tomcat customizers did in the gematik post: enforce that the client must present a certificate and that the chain must validate against your truststore. In Quarkus, this is one property plus the usual keystore and truststore wiring.
Create src/main/resources/application.properties:
quarkus.http.ssl-port=8443
quarkus.http.insecure-requests=disabled
quarkus.http.ssl.certificate.key-store-file=certs/server-keystore.p12
quarkus.http.ssl.certificate.key-store-password=changeit
quarkus.http.ssl.certificate.key-store-file-type=PKCS12
quarkus.http.ssl.certificate.trust-store-file=certs/server-truststore.p12
quarkus.http.ssl.certificate.trust-store-password=changeit
quarkus.http.ssl.certificate.trust-store-file-type=PKCS12
# mTLS: demand a client cert at the TLS layer
quarkus.http.ssl.client-auth=required
# Dev Services PostgreSQL for audit persistence
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.database.generation=update
# Allowlisted client certificate fingerprints (SHA-256, hex, uppercase)
mainthread.mtls.allowed-fingerprints=Two important consequences fall out of this.
First, if a client does not present a certificate, the connection never becomes an HTTP request. That’s the “fail early” part and it maps cleanly to the gematik intent.
Second, if a client presents a certificate that chains to your CA, the handshake succeeds even if that certificate is “wrong” for your business rules. That’s where our Quarkus security mechanism comes in.
The Certificate Validator That Enforces Your Rules
We’ll keep the rules narrowly scoped, but realistic: the certificate must be within its validity window, must contain the TLS client authentication EKU when present, and must match an allowlisted fingerprint. In real gematik environments, you would add policy OIDs, issuer constraints, and specification-specific checks, but the shape stays the same: a set of assertions that are not handled by the truststore alone.
Create src/main/java/dev/mainthread/security/CertificateValidator.java:
package dev.mainthread.security;
import java.security.MessageDigest;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped
public class CertificateValidator {
private static final String EKU_TLS_CLIENT_AUTH_OID = "1.3.6.1.5.5.7.3.2";
private final Set<String> allowedFingerprints;
private final Clock clock = Clock.systemUTC();
public CertificateValidator(
@ConfigProperty(name = "mainthread.mtls.allowed-fingerprints") Set<String> allowedFingerprints) {
this.allowedFingerprints = Objects.requireNonNullElse(allowedFingerprints, Set.of());
}
public ValidationResult validate(X509Certificate cert) {
if (cert == null) {
return ValidationResult.rejected("No client certificate presented.");
}
Instant now = clock.instant();
if (now.isBefore(cert.getNotBefore().toInstant()) || now.isAfter(cert.getNotAfter().toInstant())) {
return ValidationResult.rejected("Client certificate is outside its validity window.");
}
if (!ekuAllowsTlsClientAuth(cert)) {
return ValidationResult.rejected("Client certificate EKU does not allow TLS client authentication.");
}
String fingerprint = sha256Fingerprint(cert);
if (!allowedFingerprints.isEmpty() && !allowedFingerprints.contains(fingerprint)) {
return ValidationResult.rejected("Client certificate fingerprint is not allowlisted: " + fingerprint);
}
return ValidationResult.accepted(fingerprint);
}
private boolean ekuAllowsTlsClientAuth(X509Certificate cert) {
try {
List<String> eku = cert.getExtendedKeyUsage();
if (eku == null) {
return true;
}
return eku.contains(EKU_TLS_CLIENT_AUTH_OID);
} catch (Exception e) {
return false;
}
}
public static String sha256Fingerprint(X509Certificate cert) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(cert.getEncoded());
return HexFormat.of().withUpperCase().formatHex(digest);
} catch (CertificateEncodingException cee) {
throw new IllegalStateException("Unable to encode certificate.", cee);
} catch (Exception e) {
throw new IllegalStateException("Unable to compute certificate fingerprint.", e);
}
}
public record ValidationResult(boolean accepted, String fingerprint, String reason) {
public static ValidationResult accepted(String fingerprint) {
return new ValidationResult(true, fingerprint, null);
}
public static ValidationResult rejected(String reason) {
return new ValidationResult(false, null, reason);
}
}
}This class is intentionally boring. That’s the point. When certificate validation logic is scattered through request filters and controller code, it rots. When it’s a single injectable component, you can test it, log it, and evolve it as the spec evolves, which is exactly the kind of open, repeatable engineering gematik was advocating for.
A Quarkus Security Mechanism That Turns Certificates Into Identity
Now we need an enforcement point. In Quarkus, the cleanest place is an HttpAuthenticationMechanism that extracts the peer certificate from the TLS session and produces an authenticated SecurityIdentity only if the validator accepts it. The runtime wiring is exactly what the Quarkus security customization docs describe: the mechanism translates request data into an authentication request and delegates to identity providers.
The authentication request
Create src/main/java/dev/mainthread/security/ClientCertAuthenticationRequest.java:
package dev.mainthread.security;
import java.security.cert.X509Certificate;
import io.quarkus.security.identity.request.AuthenticationRequest;
public final class ClientCertAuthenticationRequest implements AuthenticationRequest {
private final X509Certificate certificate;
private final java.util.Map<String, Object> attributes = new java.util.concurrent.ConcurrentHashMap<>();
public ClientCertAuthenticationRequest(X509Certificate certificate) {
this.certificate = certificate;
}
public X509Certificate certificate() {
return certificate;
}
@Override
public java.util.Map<String, Object> getAttributes() {
return attributes;
}
@Override
public void setAttribute(String name, Object value) {
attributes.put(name, value);
}
@Override
public <T> T getAttribute(String name) {
return (T) attributes.get(name);
}
}The identity provider
Create src/main/java/dev/mainthread/security/ClientCertIdentityProvider.java:
package dev.mainthread.security;
import java.security.Principal;
import java.security.cert.X509Certificate;
import java.util.Set;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class ClientCertIdentityProvider implements IdentityProvider<ClientCertAuthenticationRequest> {
@Inject
CertificateValidator validator;
@Override
public Class<ClientCertAuthenticationRequest> getRequestType() {
return ClientCertAuthenticationRequest.class;
}
@Override
public Uni<SecurityIdentity> authenticate(ClientCertAuthenticationRequest request,
AuthenticationRequestContext context) {
X509Certificate cert = request.certificate();
CertificateValidator.ValidationResult result = validator.validate(cert);
if (!result.accepted()) {
return Uni.createFrom().failure(new SecurityException(result.reason()));
}
Principal principal = cert.getSubjectX500Principal();
SecurityIdentity identity = QuarkusSecurityIdentity.builder()
.setPrincipal(principal)
.addRoles(Set.of("mtls-client"))
.addAttribute("clientCertFingerprint", result.fingerprint())
.build();
return Uni.createFrom().item(identity);
}
}This is where the “handshake should have failed” intent becomes enforceable policy. If the certificate violates your rules, we fail authentication. Protected endpoints will never see an identity, and the caller gets rejected in a way that is obvious in logs and metrics.
The HTTP authentication mechanism
Create src/main/java/dev/mainthread/security/ClientCertAuthenticationMechanism.java:
package dev.mainthread.security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class ClientCertAuthenticationMechanism implements HttpAuthenticationMechanism {
@Inject
IdentityProviderManager identityProviderManager;
@Override
public Uni<SecurityIdentity> authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) {
X509Certificate cert = extractClientCert(context);
if (cert == null) {
return Uni.createFrom().nullItem();
}
return identityProviderManager.authenticate(new ClientCertAuthenticationRequest(cert))
.onFailure().recoverWithUni(failure -> {
// When authentication fails, return null to trigger challenge
return Uni.createFrom().nullItem();
});
}
@Override
public Uni<Boolean> sendChallenge(RoutingContext context) {
if (context.response().ended()) {
return Uni.createFrom().item(false);
}
context.response().setStatusCode(401).end("Client certificate rejected.");
return Uni.createFrom().item(true);
}
@Override
public Uni<io.quarkus.vertx.http.runtime.security.ChallengeData> getChallenge(RoutingContext context) {
io.quarkus.vertx.http.runtime.security.ChallengeData challengeData = new io.quarkus.vertx.http.runtime.security.ChallengeData(
401,
"Client Cert",
"Client certificate rejected.");
return Uni.createFrom().item(challengeData);
}
private X509Certificate extractClientCert(RoutingContext context) {
SSLSession session = context.request().sslSession();
if (session == null) {
return null;
}
try {
Certificate[] peer = session.getPeerCertificates();
if (peer.length == 0) {
return null;
}
if (peer[0] instanceof X509Certificate x509) {
return x509;
}
return null;
} catch (SSLPeerUnverifiedException e) {
return null;
}
}
}Notice what we did not do. We did not try to wedge ourselves into the TLS engine to abort the handshake mid-flight the way the Spring/Tomcat approach can. gematik’s post is right to care about failing as early as possible, but in Quarkus the pragmatic line is different: the TLS layer enforces trust and “cert required,” then security enforces domain rules at request time. You still reject the call before application logic runs, and you keep the policy code inside Quarkus Security where it belongs.
A Protected REST Endpoint That Shows the Identity
Create src/main/java/dev/mainthread/api/WhoAmIResource.java:
package dev.mainthread.api;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/whoami")
public class WhoAmIResource {
@Inject
SecurityIdentity identity;
@GET
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed("mtls-client")
public WhoAmIResponse whoAmI() {
String fingerprint = identity.getAttribute("clientCertFingerprint");
return new WhoAmIResponse(identity.getPrincipal().getName(), fingerprint);
}
public record WhoAmIResponse(String principal, String fingerprint) {
}
}If authentication fails, this method never runs. That’s not just nice separation. It’s the difference between “a controller that tries to be secure” and “a system that is secure by construction.”
Audit Persistence With a Real Transaction Boundary
Security incidents are rarely about the one request you saw. They’re about the fifty requests you didn’t notice until someone asked for evidence. So we’ll persist a minimal audit record every time we accept a certificate.
Create src/main/java/dev/mainthread/audit/MtlsAuditEntry.java:
package dev.mainthread.audit;
import java.time.Instant;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
@Entity
@Table(name = "mtls_audit", indexes = {
@Index(name = "idx_mtls_audit_fingerprint", columnList = "fingerprint")
})
public class MtlsAuditEntry extends PanacheEntity {
@Column(nullable = false, length = 128)
public String fingerprint;
@Column(nullable = false, length = 512)
public String principal;
@Column(nullable = false)
public Instant acceptedAt;
}Create src/main/java/dev/mainthread/audit/MtlsAuditService.java:
package dev.mainthread.audit;
import java.time.Instant;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class MtlsAuditService {
@Transactional
public void recordAccepted(String fingerprint, String principal) {
MtlsAuditEntry entry = new MtlsAuditEntry();
entry.fingerprint = fingerprint;
entry.principal = principal;
entry.acceptedAt = Instant.now();
entry.persist();
}
}Now connect it to authentication, because the audit should reflect what the security system accepted, not what a controller happened to see.
Update ClientCertIdentityProvider to inject and call the audit service:
// add imports
import dev.mainthread.audit.MtlsAuditService;
// add field
@Inject
MtlsAuditService audit;
// inside accepted branch, before building identity:
// Audit asynchronously on a worker thread to avoid blocking the IO thread
String fingerprint = result.fingerprint();
String principalName = cert.getSubjectX500Principal().getName();
Uni.createFrom().item(() -> {
audit.recordAccepted(fingerprint, principalName);
return null;
})
.runSubscriptionOn(io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool())
.subscribe().with(
v -> LOG.debugf("Audit entry recorded for fingerprint: %s", fingerprint),
failure -> LOG.errorf(failure, "Failed to record audit entry: %s", failure.getMessage())
);This is the quiet part of “production-ready.” The endpoint is not the system. The system is the endpoint plus the trail you can defend later.
Production Hardening: Where This Survives and Where It Doesn’t
When gematik talks about aborting the handshake, they’re optimizing for two things: early rejection and uniform enforcement no matter what the application does later. That’s solid thinking, and it’s the right instinct in regulated ecosystems.
Our Quarkus version gives you uniform enforcement through the security layer, but it does not abort the TLS handshake itself. The trade-off is that unauthorised clients can still complete a handshake if their chain validates, and you pay the cost of one HTTP request that gets rejected at auth time. In most enterprise setups that’s a fair trade because the expensive work is not the handshake, it’s the downstream calls you prevent by denying identity early.
The second hardening point is performance and concurrency. Certificate parsing and fingerprint hashing happen per request unless you cache. In real systems, you almost always have connection reuse, so you can cache the fingerprint per connection if you build a more advanced mechanism, but don’t do that until the metrics tell you it matters. The simpler win is allowlisting by fingerprint because the comparison is constant time and predictable under load.
Finally, don’t forget the native build story. If you later ship this as a native executable, SSL configuration and trust material handling becomes part of your build pipeline, not just runtime config, and Quarkus has specific guidance for SSL in native mode that you should follow when you cross that line.
Verification With curl and a Known Fingerprint
First, compute the client cert fingerprint and allowlist it.
cd src/main/resources/certs
CLIENT_FP=$(openssl x509 -in client.crt -noout -fingerprint -sha256 | cut -d= -f2 | tr -d :)
echo $CLIENT_FPPut that value into application.properties:
mainthread.mtls.allowed-fingerprints=PUT_THE_FINGERPRINT_HERERun the app:
quarkus devCall the endpoint with the client certificate:
curl -k https://localhost:8443/whoami \
--cacert src/main/resources/certs/ca.crt \
--cert src/main/resources/certs/client.crt \
--key src/main/resources/certs/client.keyExpected output shape:
{
"principal": "O=Example Partner,CN=partner-a",
"fingerprint": "D83C6ECA617B223E56B199FB657086E546C970560157F6893B8DF41D4848DEFB"
}You can also take a look at the logs and see what is happening in more detail if you clone the repository.
[AuthenticationMechanism] Incoming request: GET /whoami
[AuthenticationMechanism] TLS session established (TLSv1.3)
[AuthenticationMechanism] Client certificate presented
subject=O=Example Partner,CN=partner-a
issuer=CN=MainThread Demo CA
[IdentityProvider] Validating client certificate
[CertificateValidator] Fingerprint matches allowlist
[CertificateValidator] Certificate accepted
[AuthenticationMechanism] Authentication successful
principal=O=Example Partner,CN=partner-a
[REST] whoAmI() invoked
[REST] Returning identity and certificate fingerprint
[HTTP] Response: 200 OK
[Audit] Accepted certificate fingerprint recordedNow try without a client cert:
curl -k https://localhost:8443/whoami --cacert src/main/resources/certs/ca.crtYou should see the connection fail at the TLS layer because client auth is required, which is exactly the “don’t even let this become an HTTP request” behavior we want from mTLS.
LibreSSL SSL_read: ... ST_OK:reason(1116)Conclusion
Gematik’s original implementation idea is a good reminder that “turn on mTLS” is a starting point, not an outcome, and that real ecosystems need certificate checks that go beyond chain validation. By moving those checks into a Quarkus authentication mechanism, we keep the rule enforcement centralized, testable, auditable, and hard to bypass, while still letting Quarkus enforce client certificate presence at the TLS layer.
Thanks again to Robert Stäber and the Gematik team for loving OpenSource and sharing their lessons!
Here is some more reading about Quarkus and security:
TLS/SSL for Java Developers: A Practical Guide with Quarkus
In a world of APIs, cloud deployments, and microservices talking to each other across the internet, understanding TLS (Transport Layer Security) is essential. Yet many Java developers treat it as dark magic, until the app breaks in staging, a production outage happens, or a compliance checklist shows up on your desk.
Dynamic Role-Based Access in Quarkus: Fine-Grained Security Without Redeploys
Hardcoding @RolesAllowed("manager") works for demos. It crumbles when requirements change weekly. You need dynamic, fine-grained control you can change at runtime without redeploying.






As always, a very interesting article. This is a topic close to my heart. In my quarkus-mtls-auth project (https://github.com/amusarra/quarkus-mtls-auth), implementing mTLS with Quarkus goes beyond basic encryption: authentication and authorization are pushed into the core of the application to achieve true defense in depth.
At the heart of the solution is the SecurityIdentityAugmentor, which enriches the security context using X.509 client certificates - validating a unique DeviceId, mapping custom OIDs to Quarkus RBAC roles, and exposing attributes like deviceId throughout the identity.
This approach enables end-to-end security, reduces reliance on external network components, and is designed for advanced scenarios such as integration with the Italian Trusted Service List (TSL), supporting national digital identities like CIE and CNS.