Mutual TLS Authentication with Client Certificates in Java

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

  1. Strong Authentication - Cryptographically proven identity
  2. Eliminates Password Management - No passwords to store or rotate
  3. Prevents Phishing - Certificates can't be easily stolen like passwords
  4. Audit Trail - Clear identification of clients
  5. Regulatory Compliance - Meets strict security requirements
  6. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper