Introduction
Mutual TLS (mTLS) authentication provides two-way security where both the client and server verify each other's identities using X.509 certificates. This creates a highly secure communication channel ideal for microservices, APIs, and enterprise systems. This guide explores how to implement mutual authentication with client certificates in Java applications.
Article: Implementing Mutual TLS Authentication with Client Certificates in Java
Mutual TLS extends standard TLS by requiring clients to present certificates that the server validates, ensuring both parties are trusted. Java's robust security APIs make it well-suited for implementing mTLS in various scenarios.
1. Mutual TLS Architecture Overview
Key Components:
- Certificate Authority (CA) - Issues and signs certificates
- Server Certificate - Identifies the server to clients
- Client Certificate - Identifies the client to the server
- Trust Stores - Store trusted CA certificates
- Key Stores - Store private keys and certificates
- SSL Context - Configures TLS parameters and authentication
mTLS Handshake Flow:
1. Client Hello → 2. Server Hello with Certificate → 3. Client Certificate Request 4. Client Certificate → 5. Certificate Verify → 6. Finished → Secure Communication
2. Maven Dependencies
pom.xml:
<properties> <spring-boot.version>3.1.0</spring-boot.version> <bouncycastle.version>1.76</bouncycastle.version> </properties> <dependencies> <!-- Spring Boot --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- Bouncy Castle for Crypto Operations --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcpkix-jdk18on</artifactId> <version>${bouncycastle.version}</version> </dependency> <!-- HTTP Client --> <dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> <version>5.2.1</version> </dependency> <!-- Certificate Management --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.6.0</version> </dependency> </dependencies> 3. Application Configuration
application.yml:
server: port: 8443 ssl: enabled: true key-store: classpath:keystore/server.p12 key-store-password: changeit key-store-type: PKCS12 key-alias: server key-password: changeit trust-store: classpath:keystore/truststore.p12 trust-store-password: changeit trust-store-type: PKCS12 client-auth: need # Options: need, want, none enabled-protocols: TLSv1.3,TLSv1.2 ciphers: TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_AES_128_GCM_SHA256 app: mtls: # Certificate Validation crl-check-enabled: true ocsp-check-enabled: false certificate-expiry-warning-days: 30 # Client Certificate Mapping certificate-header: X-Client-Certificate subject-dn-header: X-Client-DN certificate-pinning-enabled: true # Security allowed-cipher-suites: - TLS_AES_256_GCM_SHA384 - TLS_CHACHA20_POLY1305_SHA256 - TLS_AES_128_GCM_SHA256 # Monitoring certificate-expiry-check-cron: "0 0 6 * * *" spring: datasource: url: jdbc:postgresql://localhost:5432/mtls_auth username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:password} jpa: hibernate: ddl-auto: update show-sql: true logging: level: com.myapp.mtls: DEBUG org.springframework.security: DEBUG 4. Certificate Domain Models
Client Certificate Entity:
package com.myapp.mtls.model; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import java.security.cert.X509Certificate; import java.time.LocalDateTime; import java.util.HashSet; import java.util.Set; @Entity @Table(name = "client_certificates", uniqueConstraints = { @UniqueConstraint(columnNames = "serialNumber"), @UniqueConstraint(columnNames = "subjectDN") }) public class ClientCertificate { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotNull private String serialNumber; @NotNull private String subjectDN; private String issuerDN; private String commonName; private String organization; private String organizationalUnit; private String country; @Column(columnDefinition = "TEXT") private String certificatePem; @NotNull private LocalDateTime notBefore; @NotNull private LocalDateTime notAfter; @Enumerated(EnumType.STRING) private CertificateStatus status = CertificateStatus.ACTIVE; @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "certificate_roles", joinColumns = @JoinColumn(name = "certificate_id")) @Column(name = "role") private Set<String> roles = new HashSet<>(); private String clientId; private String description; @Column(name = "created_at") private LocalDateTime createdAt; @Column(name = "updated_at") private LocalDateTime updatedAt; @Column(name = "last_used_at") private LocalDateTime lastUsedAt; // Constructors public ClientCertificate() { this.createdAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now(); } public ClientCertificate(X509Certificate certificate) { this(); updateFromCertificate(certificate); } // Business Methods public void updateFromCertificate(X509Certificate certificate) { this.serialNumber = certificate.getSerialNumber().toString(); this.subjectDN = certificate.getSubjectX500Principal().getName(); this.issuerDN = certificate.getIssuerX500Principal().getName(); this.notBefore = certificate.getNotBefore().toInstant() .atZone(java.time.ZoneId.systemDefault()).toLocalDateTime(); this.notAfter = certificate.getNotAfter().toInstant() .atZone(java.time.ZoneId.systemDefault()).toLocalDateTime(); // Extract common name from subject DN this.commonName = extractCommonName(subjectDN); this.updatedAt = LocalDateTime.now(); } public boolean isActive() { return status == CertificateStatus.ACTIVE && !isExpired() && !isNotYetValid(); } public boolean isExpired() { return LocalDateTime.now().isAfter(notAfter); } public boolean isNotYetValid() { return LocalDateTime.now().isBefore(notBefore); } public boolean needsRenewal() { return notAfter.minusDays(30).isBefore(LocalDateTime.now()); } public boolean hasRole(String role) { return roles.contains(role) || roles.contains("ROLE_ADMIN"); } private String extractCommonName(String subjectDN) { // Simple extraction - in production, use a proper X500Name parser String[] parts = subjectDN.split(","); for (String part : parts) { if (part.trim().startsWith("CN=")) { return part.trim().substring(3); } } return subjectDN; } // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getSerialNumber() { return serialNumber; } public void setSerialNumber(String serialNumber) { this.serialNumber = serialNumber; } public String getSubjectDN() { return subjectDN; } public void setSubjectDN(String subjectDN) { this.subjectDN = subjectDN; } public String getIssuerDN() { return issuerDN; } public void setIssuerDN(String issuerDN) { this.issuerDN = issuerDN; } public String getCommonName() { return commonName; } public void setCommonName(String commonName) { this.commonName = commonName; } public String getOrganization() { return organization; } public void setOrganization(String organization) { this.organization = organization; } public String getOrganizationalUnit() { return organizationalUnit; } public void setOrganizationalUnit(String organizationalUnit) { this.organizationalUnit = organizationalUnit; } public String getCountry() { return country; } public void setCountry(String country) { this.country = country; } public String getCertificatePem() { return certificatePem; } public void setCertificatePem(String certificatePem) { this.certificatePem = certificatePem; } public LocalDateTime getNotBefore() { return notBefore; } public void setNotBefore(LocalDateTime notBefore) { this.notBefore = notBefore; } public LocalDateTime getNotAfter() { return notAfter; } public void setNotAfter(LocalDateTime notAfter) { this.notAfter = notAfter; } public CertificateStatus getStatus() { return status; } public void setStatus(CertificateStatus status) { this.status = status; } public Set<String> getRoles() { return roles; } public void setRoles(Set<String> roles) { this.roles = roles; } public String getClientId() { return clientId; } public void setClientId(String clientId) { this.clientId = clientId; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } public LocalDateTime getLastUsedAt() { return lastUsedAt; } public void setLastUsedAt(LocalDateTime lastUsedAt) { this.lastUsedAt = lastUsedAt; } @PreUpdate public void preUpdate() { this.updatedAt = LocalDateTime.now(); } } enum CertificateStatus { ACTIVE, REVOKED, EXPIRED, SUSPENDED } 5. SSL Context Configuration
SSL Context Configuration:
package com.myapp.mtls.config; import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; import org.apache.hc.core5.ssl.SSLContexts; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.util.ResourceUtils; import javax.net.ssl.*; import java.io.FileInputStream; import java.security.KeyStore; import java.security.SecureRandom; @Configuration public class SSLContextConfig { @Value("${server.ssl.key-store}") private Resource keyStoreResource; @Value("${server.ssl.key-store-password}") private String keyStorePassword; @Value("${server.ssl.trust-store}") private Resource trustStoreResource; @Value("${server.ssl.trust-store-password}") private String trustStorePassword; @Value("${server.ssl.key-alias}") private String keyAlias; @Bean public SSLContext sslContext() throws Exception { // Load server key store KeyStore keyStore = KeyStore.getInstance("PKCS12"); try (FileInputStream fis = new FileInputStream(ResourceUtils.getFile(keyStoreResource.getURL()))) { keyStore.load(fis, keyStorePassword.toCharArray()); } // Load trust store (CA certificates) KeyStore trustStore = KeyStore.getInstance("PKCS12"); try (FileInputStream fis = new FileInputStream(ResourceUtils.getFile(trustStoreResource.getURL()))) { trustStore.load(fis, trustStorePassword.toCharArray()); } // Create key manager factory KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); keyManagerFactory.init(keyStore, keyStorePassword.toCharArray()); // Create trust manager factory TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); trustManagerFactory.init(trustStore); // Create SSL context with mutual authentication SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init( keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), new SecureRandom() ); return sslContext; } @Bean public SSLSocketFactory sslSocketFactory(SSLContext sslContext) { return sslContext.getSocketFactory(); } @Bean public SSLConnectionSocketFactory sslConnectionSocketFactory(SSLContext sslContext) { return new SSLConnectionSocketFactory( sslContext, new String[]{"TLSv1.3", "TLSv1.2"}, null, SSLConnectionSocketFactory.getDefaultHostnameVerifier() ); } @Bean public SSLParameters sslParameters() { SSLParameters sslParameters = new SSLParameters(); sslParameters.setNeedClientAuth(true); // Require client authentication sslParameters.setWantClientAuth(true); // Request but don't require client auth sslParameters.setCipherSuites(new String[]{ "TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256", "TLS_AES_128_GCM_SHA256" }); sslParameters.setProtocols(new String[]{"TLSv1.3", "TLSv1.2"}); return sslParameters; } } 6. Client Certificate Authentication
Client Certificate Authentication Provider:
package com.myapp.mtls.auth; import com.myapp.mtls.model.ClientCertificate; import com.myapp.mtls.service.ClientCertificateService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; import java.security.cert.X509Certificate; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @Component public class ClientCertificateAuthenticationProvider implements AuthenticationProvider { private static final Logger logger = LoggerFactory.getLogger(ClientCertificateAuthenticationProvider.class); private final ClientCertificateService certificateService; public ClientCertificateAuthenticationProvider(ClientCertificateService certificateService) { this.certificateService = certificateService; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { if (!(authentication instanceof ClientCertificateAuthentication)) { return null; } ClientCertificateAuthentication clientCertAuth = (ClientCertificateAuthentication) authentication; X509Certificate[] clientCertificates = clientCertAuth.getClientCertificates(); if (clientCertificates == null || clientCertificates.length == 0) { throw new BadCredentialsException("No client certificate provided"); } // Use the first certificate in the chain (the client's certificate) X509Certificate clientCertificate = clientCertificates[0]; try { // Validate certificate against our database Optional<ClientCertificate> validatedCert = certificateService.validateCertificate(clientCertificate); if (validatedCert.isEmpty()) { throw new BadCredentialsException("Client certificate not authorized: " + clientCertificate.getSubjectX500Principal().getName()); } ClientCertificate certificate = validatedCert.get(); if (!certificate.isActive()) { throw new BadCredentialsException("Client certificate is not active: " + certificate.getStatus()); } // Extract authorities from certificate roles List<SimpleGrantedAuthority> authorities = certificate.getRoles().stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); // Create successful authentication ClientCertificateAuthentication successfulAuth = new ClientCertificateAuthentication( clientCertificates, certificate.getSubjectDN(), authorities, certificate ); successfulAuth.setAuthenticated(true); // Update last used timestamp certificateService.updateLastUsed(certificate.getId()); logger.info("Client certificate authentication successful for: {}", certificate.getSubjectDN()); return successfulAuth; } catch (Exception e) { logger.error("Client certificate authentication failed", e); throw new BadCredentialsException("Certificate validation failed", e); } } @Override public boolean supports(Class<?> authentication) { return ClientCertificateAuthentication.class.isAssignableFrom(authentication); } } Custom Authentication Object:
package com.myapp.mtls.auth; import com.myapp.mtls.model.ClientCertificate; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import java.security.cert.X509Certificate; import java.util.Collection; import java.util.Collections; public class ClientCertificateAuthentication implements Authentication { private final X509Certificate[] clientCertificates; private final String principal; private final Collection<? extends GrantedAuthority> authorities; private final ClientCertificate clientCertificate; private boolean authenticated = false; public ClientCertificateAuthentication(X509Certificate[] clientCertificates) { this(clientCertificates, null, Collections.emptyList(), null); } public ClientCertificateAuthentication(X509Certificate[] clientCertificates, String principal, Collection<? extends GrantedAuthority> authorities, ClientCertificate clientCertificate) { this.clientCertificates = clientCertificates; this.principal = principal; this.authorities = authorities; this.clientCertificate = clientCertificate; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @Override public Object getCredentials() { return clientCertificates; } @Override public Object getDetails() { return clientCertificate; } @Override public Object getPrincipal() { return principal; } @Override public boolean isAuthenticated() { return authenticated; } @Override public void setAuthenticated(boolean authenticated) throws IllegalArgumentException { this.authenticated = authenticated; } @Override public String getName() { return principal; } // Custom getters public X509Certificate[] getClientCertificates() { return clientCertificates; } public ClientCertificate getClientCertificate() { return clientCertificate; } public String getSubjectDN() { return clientCertificates != null && clientCertificates.length > 0 ? clientCertificates[0].getSubjectX500Principal().getName() : null; } public String getIssuerDN() { return clientCertificates != null && clientCertificates.length > 0 ? clientCertificates[0].getIssuerX500Principal().getName() : null; } } 7. Client Certificate Filter
Certificate Extraction Filter:
package com.myapp.mtls.auth; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.security.cert.X509Certificate; public class ClientCertificateFilter extends OncePerRequestFilter { private static final Logger logger = LoggerFactory.getLogger(ClientCertificateFilter.class); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // Extract client certificate from request X509Certificate[] clientCertificates = extractClientCertificates(request); if (clientCertificates != null && clientCertificates.length > 0) { try { // Create authentication object ClientCertificateAuthentication authentication = new ClientCertificateAuthentication(clientCertificates); // Set in security context for authentication provider to process SecurityContextHolder.getContext().setAuthentication(authentication); logger.debug("Extracted client certificate: {}", clientCertificates[0].getSubjectX500Principal().getName()); } catch (Exception e) { logger.error("Failed to process client certificate", e); SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); } private X509Certificate[] extractClientCertificates(HttpServletRequest request) { // Try to get certificates from SSL attributes X509Certificate[] certs = (X509Certificate[]) request.getAttribute("jakarta.servlet.request.X509Certificate"); if (certs == null || certs.length == 0) { // Fallback to alternative attribute names certs = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate"); } if (certs == null || certs.length == 0) { logger.debug("No client certificates found in request"); return null; } return certs; } @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); // Skip certificate authentication for health checks and public endpoints return path.startsWith("/actuator/health") || path.startsWith("/public/") || path.equals("/error"); } } 8. Client Certificate Service
Certificate Management Service:
package com.myapp.mtls.service; import com.myapp.mtls.model.ClientCertificate; import com.myapp.mtls.model.CertificateStatus; import com.myapp.mtls.repository.ClientCertificateRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.security.cert.X509Certificate; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @Service public class ClientCertificateService { private static final Logger logger = LoggerFactory.getLogger(ClientCertificateService.class); private final ClientCertificateRepository certificateRepository; public ClientCertificateService(ClientCertificateRepository certificateRepository) { this.certificateRepository = certificateRepository; } public Optional<ClientCertificate> validateCertificate(X509Certificate certificate) { String serialNumber = certificate.getSerialNumber().toString(); String subjectDN = certificate.getSubjectX500Principal().getName(); // Look up certificate by serial number and subject DN Optional<ClientCertificate> foundCert = certificateRepository .findBySerialNumberAndSubjectDN(serialNumber, subjectDN); if (foundCert.isPresent()) { ClientCertificate clientCert = foundCert.get(); // Verify certificate is active and valid if (clientCert.isActive()) { // Additional validation could include: // - Certificate chain validation // - CRL checks // - OCSP validation // - Certificate pinning return Optional.of(clientCert); } } return Optional.empty(); } @Transactional public ClientCertificate registerCertificate(X509Certificate certificate, String clientId, List<String> roles, String description) { String serialNumber = certificate.getSerialNumber().toString(); String subjectDN = certificate.getSubjectX500Principal().getName(); // Check if certificate already exists Optional<ClientCertificate> existingCert = certificateRepository .findBySerialNumberAndSubjectDN(serialNumber, subjectDN); if (existingCert.isPresent()) { throw new CertificateAlreadyExistsException( "Certificate already registered: " + subjectDN); } // Create new certificate record ClientCertificate clientCert = new ClientCertificate(certificate); clientCert.setClientId(clientId); clientCert.setRoles(roles != null ? new java.util.HashSet<>(roles) : new java.util.HashSet<>()); clientCert.setDescription(description); clientCert.setStatus(CertificateStatus.ACTIVE); return certificateRepository.save(clientCert); } @Transactional public void revokeCertificate(Long certificateId, String reason) { certificateRepository.findById(certificateId).ifPresent(certificate -> { certificate.setStatus(CertificateStatus.REVOKED); certificateRepository.save(certificate); logger.info("Certificate revoked: {} - Reason: {}", certificate.getSubjectDN(), reason); }); } @Transactional public void updateLastUsed(Long certificateId) { certificateRepository.findById(certificateId).ifPresent(certificate -> { certificate.setLastUsedAt(LocalDateTime.now()); certificateRepository.save(certificate); }); } public List<ClientCertificate> getExpiringCertificates(int daysThreshold) { LocalDateTime thresholdDate = LocalDateTime.now().plusDays(daysThreshold); return certificateRepository.findByNotAfterBeforeAndStatus(thresholdDate, CertificateStatus.ACTIVE); } public List<ClientCertificate> getRevokedCertificates() { return certificateRepository.findByStatus(CertificateStatus.REVOKED); } @Scheduled(cron = "${app.mtls.certificate-expiry-check-cron:0 0 6 * * *}") @Transactional public void expireCertificates() { LocalDateTime now = LocalDateTime.now(); List<ClientCertificate> expiredCerts = certificateRepository .findByNotAfterBeforeAndStatus(now, CertificateStatus.ACTIVE); for (ClientCertificate cert : expiredCerts) { cert.setStatus(CertificateStatus.EXPIRED); logger.info("Certificate expired: {}", cert.getSubjectDN()); } certificateRepository.saveAll(expiredCerts); } public Optional<ClientCertificate> getCertificateBySubjectDN(String subjectDN) { return certificateRepository.findBySubjectDN(subjectDN); } public List<ClientCertificate> getCertificatesByClientId(String clientId) { return certificateRepository.findByClientId(clientId); } // Custom Exceptions public static class CertificateAlreadyExistsException extends RuntimeException { public CertificateAlreadyExistsException(String message) { super(message); } } } 9. Security Configuration
Security Configuration:
package com.myapp.mtls.config; import com.myapp.mtls.auth.ClientCertificateAuthenticationProvider; import com.myapp.mtls.auth.ClientCertificateFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @EnableMethodSecurity(prePostEnabled = true) public class SecurityConfig { private final ClientCertificateAuthenticationProvider certificateAuthProvider; public SecurityConfig(ClientCertificateAuthenticationProvider certificateAuthProvider) { this.certificateAuthProvider = certificateAuthProvider; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authz -> authz .requestMatchers("/actuator/health", "/public/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/certificates/**").hasRole("ADMIN") .anyRequest().authenticated() ) .authenticationProvider(certificateAuthProvider) .addFilterBefore( new ClientCertificateFilter(), UsernamePasswordAuthenticationFilter.class ); return http.build(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } } 10. REST Controllers
Certificate Management Controller:
package com.myapp.mtls.controller; import com.myapp.mtls.model.ClientCertificate; import com.myapp.mtls.service.ClientCertificateService; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.Base64; import java.util.List; @RestController @RequestMapping("/api/certificates") public class CertificateController { private final ClientCertificateService certificateService; public CertificateController(ClientCertificateService certificateService) { this.certificateService = certificateService; } @PostMapping @PreAuthorize("hasRole('ADMIN')") public ResponseEntity<?> registerCertificate(@Valid @RequestBody RegisterCertificateRequest request) { try { // Parse certificate from PEM X509Certificate certificate = parseCertificate(request.getCertificatePem()); // Register certificate ClientCertificate registeredCert = certificateService.registerCertificate( certificate, request.getClientId(), request.getRoles(), request.getDescription() ); return ResponseEntity.ok(new CertificateResponse(registeredCert)); } catch (CertificateException e) { return ResponseEntity.badRequest().body(new ErrorResponse("Invalid certificate: " + e.getMessage())); } catch (ClientCertificateService.CertificateAlreadyExistsException e) { return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); } } @GetMapping @PreAuthorize("hasRole('ADMIN')") public ResponseEntity<List<ClientCertificate>> getAllCertificates() { List<ClientCertificate> certificates = certificateService.getAllCertificates(); return ResponseEntity.ok(certificates); } @GetMapping("/expiring") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity<List<ClientCertificate>> getExpiringCertificates( @RequestParam(defaultValue = "30") int days) { List<ClientCertificate> certificates = certificateService.getExpiringCertificates(days); return ResponseEntity.ok(certificates); } @DeleteMapping("/{certificateId}") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity<?> revokeCertificate(@PathVariable Long certificateId, @RequestParam String reason) { certificateService.revokeCertificate(certificateId, reason); return ResponseEntity.ok().build(); } @GetMapping("/my-certificate") public ResponseEntity<?> getMyCertificate() { // This would be called by a client to get their own certificate info // Implementation depends on how you identify the client return ResponseEntity.ok().build(); } private X509Certificate parseCertificate(String pemCertificate) throws CertificateException { // Remove PEM headers and footers String base64Certificate = pemCertificate .replace("-----BEGIN CERTIFICATE-----", "") .replace("-----END CERTIFICATE-----", "") .replaceAll("\\s", ""); byte[] certBytes = Base64.getDecoder().decode(base64Certificate); CertificateFactory factory = CertificateFactory.getInstance("X.509"); return (X509Certificate) factory.generateCertificate(new java.io.ByteArrayInputStream(certBytes)); } // Request/Response DTOs public static class RegisterCertificateRequest { private String certificatePem; private String clientId; private List<String> roles; private String description; // Getters and setters public String getCertificatePem() { return certificatePem; } public void setCertificatePem(String certificatePem) { this.certificatePem = certificatePem; } public String getClientId() { return clientId; } public void setClientId(String clientId) { this.clientId = clientId; } public List<String> getRoles() { return roles; } public void setRoles(List<String> roles) { this.roles = roles; } public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } } public static class CertificateResponse { private final Long id; private final String subjectDN; private final String status; private final String clientId; public CertificateResponse(ClientCertificate certificate) { this.id = certificate.getId(); this.subjectDN = certificate.getSubjectDN(); this.status = certificate.getStatus().name(); this.clientId = certificate.getClientId(); } // Getters public Long getId() { return id; } public String getSubjectDN() { return subjectDN; } public String getStatus() { return status; } public String getClientId() { return clientId; } } public static class ErrorResponse { private final String error; public ErrorResponse(String error) { this.error = error; } public String getError() { return error; } } } Secure API Controller:
package com.myapp.mtls.controller; import com.myapp.mtls.auth.ClientCertificateAuthentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping("/api/secure") public class SecureApiController { @GetMapping("/user-info") public Map<String, Object> getUserInfo(@AuthenticationPrincipal ClientCertificateAuthentication authentication) { Map<String, Object> userInfo = new HashMap<>(); userInfo.put("subjectDN", authentication.getSubjectDN()); userInfo.put("issuerDN", authentication.getIssuerDN()); userInfo.put("authorities", authentication.getAuthorities()); userInfo.put("authenticated", authentication.isAuthenticated()); if (authentication.getClientCertificate() != null) { userInfo.put("clientId", authentication.getClientCertificate().getClientId()); userInfo.put("certificateStatus", authentication.getClientCertificate().getStatus()); } return userInfo; } @GetMapping("/admin-data") public Map<String, String> getAdminData() { // This endpoint requires ADMIN role return Map.of( "message", "This is sensitive admin data", "accessLevel", "HIGH" ); } @GetMapping("/client-data") public Map<String, String> getClientData(@AuthenticationPrincipal ClientCertificateAuthentication authentication) { return Map.of( "message", "Data for client: " + authentication.getClientCertificate().getClientId(), "certificateSubject", authentication.getSubjectDN() ); } } 11. HTTP Client with mTLS
mTLS HTTP Client:
package com.myapp.mtls.client; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; import org.apache.hc.core5.ssl.SSLContexts; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; import javax.net.ssl.SSLContext; import java.io.FileInputStream; import java.security.KeyStore; @Configuration public class MtlsClientConfig { @Value("${client.ssl.key-store}") private Resource clientKeyStore; @Value("${client.ssl.key-store-password}") private String clientKeyStorePassword; @Value("${client.ssl.trust-store}") private Resource clientTrustStore; @Value("${client.ssl.trust-store-password}") private String clientTrustStorePassword; @Bean public RestTemplate mtlsRestTemplate() throws Exception { // Load client key store KeyStore keyStore = KeyStore.getInstance("PKCS12"); try (FileInputStream fis = new FileInputStream(clientKeyStore.getFile())) { keyStore.load(fis, clientKeyStorePassword.toCharArray()); } // Load trust store KeyStore trustStore = KeyStore.getInstance("PKCS12"); try (FileInputStream fis = new FileInputStream(clientTrustStore.getFile())) { trustStore.load(fis, clientTrustStorePassword.toCharArray()); } // Create SSL context with client certificate SSLContext sslContext = SSLContexts.custom() .loadKeyMaterial(keyStore, clientKeyStorePassword.toCharArray()) .loadTrustMaterial(trustStore, null) .build(); // Create SSL socket factory SSLConnectionSocketFactory socketFactory = new SSLConnectionSocketFactory(sslContext); // Create connection manager PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(100); connectionManager.setDefaultMaxPerRoute(20); // Create HTTP client HttpClient httpClient = HttpClients.custom() .setConnectionManager(connectionManager) .setSSLSocketFactory(socketFactory) .build(); // Create RestTemplate with mTLS client HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); requestFactory.setConnectTimeout(5000); requestFactory.setReadTimeout(30000); return new RestTemplate(requestFactory); } } Example Client Usage:
package com.myapp.mtls.client; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.util.Map; @Service public class SecureApiClient { private final RestTemplate mtlsRestTemplate; public SecureApiClient(@Qualifier("mtlsRestTemplate") RestTemplate mtlsRestTemplate) { this.mtlsRestTemplate = mtlsRestTemplate; } public Map<String, Object> callSecureApi(String url) { return mtlsRestTemplate.getForObject(url, Map.class); } public String callSecureEndpoint() { String apiUrl = "https://secure-api.mycompany.com:8443/api/secure/user-info"; Map<String, Object> response = callSecureApi(apiUrl); return "Response: " + response; } } 12. Repository Layer
Client Certificate Repository:
package com.myapp.mtls.repository; import com.myapp.mtls.model.ClientCertificate; import com.myapp.mtls.model.CertificateStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @Repository public interface ClientCertificateRepository extends JpaRepository<ClientCertificate, Long> { Optional<ClientCertificate> findBySerialNumberAndSubjectDN(String serialNumber, String subjectDN); Optional<ClientCertificate> findBySubjectDN(String subjectDN); List<ClientCertificate> findByClientId(String clientId); List<ClientCertificate> findByStatus(CertificateStatus status); List<ClientCertificate> findByNotAfterBeforeAndStatus(LocalDateTime date, CertificateStatus status); @Query("SELECT c FROM ClientCertificate c WHERE c.notAfter < ?1 AND c.status = 'ACTIVE'") List<ClientCertificate> findExpiringSoon(LocalDateTime date); List<ClientCertificate> findByLastUsedAtBefore(LocalDateTime date); long countByStatus(CertificateStatus status); @Query("SELECT c FROM ClientCertificate c WHERE c.clientId = ?1 AND c.status = 'ACTIVE'") List<ClientCertificate> findActiveByClientId(String clientId); } 13. Benefits of Mutual TLS Authentication
- Strong Authentication - Cryptographically proven identity
- Eliminates Password Management - No passwords to store or rotate
- Prevents Phishing - Certificates can't be easily stolen like passwords
- Audit Trail - Clear identification of clients
- Regulatory Compliance - Meets strict security requirements
- Microservices Security - Ideal for service-to-service communication
Conclusion
Implementing mutual TLS authentication with client certificates in Java provides enterprise-grade security for your applications. By leveraging Spring Security and Java's robust SSL/TLS capabilities, you can create a secure system that validates both client and server identities.
The key to successful mTLS implementation is:
- Proper certificate management with secure storage and validation
- Comprehensive certificate lifecycle management including expiration and revocation
- Secure configuration of SSL contexts and protocols
- Robust error handling for certificate validation failures
- Monitoring and alerting for certificate expiration and security events
Start with a development environment to test the mTLS setup, then gradually implement in production with proper certificate authority integration and monitoring.
Call to Action: Begin by setting up a test CA and generating test certificates. Implement basic mTLS authentication in a development environment, then gradually add features like certificate revocation, role-based access control, and comprehensive monitoring before deploying to production.