Skip to content

CWE-295: Improper Certificate Validation - Java

Overview

Improper certificate validation in Java occurs when code installs an X509TrustManager that accepts every certificate - on one SSLContext or as the JVM default - or disables hostname verification on HttpsURLConnection. These patterns are common as development workarounds for self-signed certificates that are never removed before deployment.

The JVM's default trust chain validates the server's certificate against the Java cacerts keystore, a well-maintained set of trusted root CAs. cacerts is a distinct store from the operating system's, and whether the two are connected depends on how the JDK was installed rather than on Java itself: several Linux distributions ship a cacerts wired into their system CA tooling, so update-ca-certificates or update-ca-trust updates it too, while a JDK unpacked from an archive or baked into a container image usually has no such link. Do not assume either way - add the CA, then make one request from the JVM and see. Hostname verification is a separate step and is not part of that default: HttpsURLConnection and java.net.http.HttpClient perform it, while a raw SSLSocket or SSLEngine does not until endpoint identification is switched on. See Raw SSLSocket with No Endpoint Identification below. Modern certificates should use Subject Alternative Name (SAN); Common Name fallback is legacy compatibility, not the target state. Replacing the default trust manager with an empty one eliminates the trust-chain check and often appears alongside a hostname-verifier bypass, allowing a man-in-the-middle attacker to present any certificate and silently intercept the decrypted traffic.

Primary Defence: Remove all custom trust managers and hostname verifiers, and use HttpClient (Java 11+) with its default SSL configuration. For internal CA certificates, load the CA into a KeyStore and build a proper TrustManagerFactory - do not bypass validation. Where the code talks to a raw SSLSocket rather than through an HTTP client, removing the bypass is not enough: hostname verification is off by default on a socket and has to be switched on with SSLParameters.setEndpointIdentificationAlgorithm("HTTPS").

Common Vulnerable Patterns

Empty X509TrustManager

// VULNERABLE - empty trust manager accepts every certificate
TrustManager[] trustAllCerts = new TrustManager[] {
    new X509TrustManager() {
        public X509Certificate[] getAcceptedIssuers() { return null; }
        public void checkClientTrusted(X509Certificate[] certs, String authType) { } // no-op
        public void checkServerTrusted(X509Certificate[] certs, String authType) { } // no-op
    }
};
SSLContext sc = SSLContext.getInstance("TLS");
sc.init(null, trustAllCerts, new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());

Why this is vulnerable:

  • checkServerTrusted() is the method where certificate chain validation occurs. Leaving it empty means no validation happens - any certificate is accepted, including an attacker's.

Trust-All HostnameVerifier

// VULNERABLE - disables hostname verification globally
HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true);

Why this is vulnerable:

  • Even with valid chain validation, a certificate for evil.attacker.com could be used to impersonate api.example.com. Hostname verification ensures the certificate was issued for the domain being connected to. Returning true unconditionally removes this check.

Combining Both Bypasses

// VULNERABLE - both chain validation and hostname verification disabled
SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, trustAllCerts, null);
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setSSLSocketFactory(sc.getSocketFactory());
conn.setHostnameVerifier((host, session) -> true);

Why this is vulnerable:

  • Disabling both checks means an attacker with network access can intercept any HTTPS connection by presenting a certificate for any domain issued by any CA (or a self-signed certificate).

Apache HttpClient Trust-All Strategy

// VULNERABLE - accepts any chain and skips the hostname check (HttpClient 5.4+)
SSLContext sslContext = SSLContexts.custom()
    .loadTrustMaterial(null, TrustAllStrategy.INSTANCE)   // or TrustSelfSignedStrategy
    .build();

TlsSocketStrategy tls = ClientTlsStrategyBuilder.create()
    .setSslContext(sslContext)
    .setHostnameVerifier(NoopHostnameVerifier.INSTANCE)
    .buildClassic();

CloseableHttpClient client = HttpClients.custom()
    .setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
        .setTlsSocketStrategy(tls).build())
    .build();

Why this is vulnerable:

  • TrustAllStrategy is an X509TrustManager that returns true for every chain, and NoopHostnameVerifier is the trust-all HostnameVerifier under a name that does not read as one. Together they are the empty-trust-manager pattern above, assembled from library classes, so the code contains no anonymous inner class for a reviewer to notice.
  • TrustSelfSignedStrategy looks narrower and is not: it accepts any self-signed certificate, including one the attacker generated a moment ago, because "self-signed" is a property of the certificate and not a statement about who issued it. If the goal is to trust one specific development certificate, load that certificate into a KeyStore and pass it to loadTrustMaterial as the trust store.
  • The three class names are what to search for; the wiring around them moved in 5.4, where ClientTlsStrategyBuilder.build()/setTlsStrategy() became buildClassic()/setTlsSocketStrategy(). Older code will read .build() and .setTlsStrategy(tls) with a TlsStrategy, and is the same weakness. HttpClient 4.x expresses it as SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE).

Raw SSLSocket with No Endpoint Identification

// VULNERABLE - no custom TrustManager, and still no hostname check
SSLSocket socket = (SSLSocket) SSLSocketFactory.getDefault()
    .createSocket("api.example.com", 443);
socket.startHandshake();
socket.getOutputStream().write(credentials);

Why this is vulnerable:

  • Nothing here is disabled. The default SSLSocketFactory uses the JVM cacerts trust store and verifies the chain, so the code passes every review that asks "is there a trust-all TrustManager?" - and it never checks that the certificate was issued for api.example.com. Hostname verification in the JSSE lives on SSLParameters, not in the trust manager, and it is off unless something turns it on. HttpsURLConnection and java.net.http.HttpClient turn it on for you; a socket you create yourself does not. Verified on JDK 26: this snippet completes the handshake against wrong.host.badssl.com, whose certificate names neither that host nor anything like it.
  • Any certificate from any public CA is therefore accepted, so the attacker needs only a valid certificate for a domain they own. This is the failure mode behind hand-written TLS clients: LDAPS and SMTP-over-TLS helpers, JDBC drivers configured through raw sockets, and anything wrapping a protocol the JDK has no HTTPS-shaped client for.

JVM Flags That Disable Verification

# VULNERABLE - turns off hostname verification for java.net.http.HttpClient
java -Djdk.internal.httpclient.disableHostnameVerification=true -jar app.jar

# VULNERABLE - every JSSE client now trusts whatever is in that keystore
java -Djavax.net.ssl.trustStore=/opt/app/extra-roots.p12 \
     -Djavax.net.ssl.trustStorePassword=changeit -jar app.jar

Why this is vulnerable:

  • jdk.internal.httpclient.disableHostnameVerification is read by the JDK's own HttpClient and, when set, accepts a certificate issued for any name. Verified on JDK 26: with the flag, a request to https://wrong.host.badssl.com/ returns 200; without it, the same request throws SSLHandshakeException. There is no application code to review and no scanner rule to trip - it appears in a container entrypoint, a JAVA_TOOL_OPTIONS value or a systemd unit.
  • javax.net.ssl.trustStore replaces cacerts for every JSSE client in the process. Validation stays on and the certificate is genuinely verified - against whatever roots that file holds, which is the more useful attack: nothing fails, nothing warns, and one certificate signed by the added CA reads every connection. The same command is also the legitimate way to ship a corporate CA, so this is a review question rather than a grep. Ask where the keystore comes from and who can write it, and check what is in it with keytool -list -keystore.
  • Grep deployment manifests, Dockerfiles, CI job definitions and JAVA_TOOL_OPTIONS for these, not just source. Removing the flag is the whole fix; there is no code change to make.

Secure Patterns

Default HttpClient (Java 11+)

import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;

// SECURE - default HttpClient validates certificates against the JVM trust store
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.example.com/data"))
    .GET()
    .build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());

Why this works:

  • HttpClient.newHttpClient() uses the JVM default SSLContext, which loads the cacerts trust store and enables hostname verification. An invalid certificate throws SSLHandshakeException.

Custom Internal CA via KeyStore

import javax.net.ssl.*;
import java.security.KeyStore;
import java.io.FileInputStream;
import java.net.http.HttpClient;

// SECURE - load a trust store containing the internal CA and any other roots this client needs
KeyStore trustStore = KeyStore.getInstance("PKCS12");  // JDK default since Java 9; JKS is the proprietary legacy format
try (FileInputStream fis = new FileInputStream("internal-ca.p12")) {
    String password = System.getenv("TRUSTSTORE_PASSWORD");
    if (password == null) {
        throw new IllegalStateException("TRUSTSTORE_PASSWORD is not set");
    }
    trustStore.load(fis, password.toCharArray());
}

TrustManagerFactory tmf = TrustManagerFactory.getInstance(
    TrustManagerFactory.getDefaultAlgorithm()); // "PKIX"
tmf.init(trustStore);

SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tmf.getTrustManagers(), null);

// SECURE - HttpClient with custom trust store; hostname verification still active
HttpClient client = HttpClient.newBuilder()
    .sslContext(sslContext)
    .build();

Why this works:

  • The TrustManagerFactory validates certificates against the supplied KeyStore. If this file contains only the internal CA, only certificates chaining to that CA are trusted. If the same client also calls public endpoints, give it a trust store holding both the internal CA and the required public roots - a copy of the default trust store with the CA imported into it. Hostname verification is performed by HttpClient independently.
  • How the .p12 was made matters. The JDK treats a PKCS12 certificate as a trust anchor only when the entry carries the trusted-certificate attribute that keytool -importcert writes. A file holding the CA as a plain certificate bag - what most non-Java tooling produces, and what openssl pkcs12 -export -nokeys produces without the -jdktrust option OpenSSL 3.2 added for exactly this - loads without complaint and then fails on the first handshake with InvalidAlgorithmParameterException: the trustAnchors parameter must be non-empty. Measured on JDK 26 with both kinds of file: the same CA, accepted from one and invisible in the other. Build the store with keytool, or check it with keytool -list and expect trustedCertEntry.

Raw SSLSocket with Endpoint Identification Enabled

import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;

// SECURE - switch on hostname verification before the handshake
SSLSocket socket = (SSLSocket) SSLSocketFactory.getDefault()
    .createSocket("api.example.com", 443);

SSLParameters params = socket.getSSLParameters();
params.setEndpointIdentificationAlgorithm("HTTPS");  // off by default on a socket
socket.setSSLParameters(params);

socket.startHandshake();  // throws SSLHandshakeException on a name mismatch

Why this works:

  • setEndpointIdentificationAlgorithm("HTTPS") makes the JSSE apply RFC 2818 name matching during the handshake, comparing the peer certificate's Subject Alternative Names against the name the socket was created for. The check runs inside startHandshake(), so a mismatch fails before any application data is written - which is the property that matters, since a verifier called after the handshake has already let the credentials out.
  • Set it on the SSLParameters before startHandshake(). Measured on JDK 26: calling setSSLParameters() afterwards raises nothing and leaves the already-negotiated session in place, so the wrong-host connection stays open and the code reads as protected while the first exchange went out unverified.
  • Pass the hostname, not an IP address, to createSocket(). An IP literal is not sent as SNI, so the server may not even serve the certificate you expected - connecting to a resolved address of www.google.com on JDK 26 fails with PKIX path building failed rather than a name mismatch, which sends you debugging the trust store instead of the address. If you must connect to a specific address, open a plain Socket to it and wrap it with SSLSocketFactory.createSocket(Socket, host, port, autoClose), passing the intended hostname; that path verified successfully in the same run.

Legacy HttpsURLConnection with Default Validation

import javax.net.ssl.HttpsURLConnection;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;

// SECURE - do NOT set a custom SSLSocketFactory or HostnameVerifier
// new URL(String) is deprecated since Java 20
URL url = URI.create("https://api.example.com/data").toURL();
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setRequestMethod("GET");

try (InputStream in = conn.getInputStream()) {
    // connection validated by default JVM trust store
}

Why this works:

  • HttpsURLConnection uses the JVM default trust chain and hostname verifier when no custom ones are assigned, so removing the custom assignments is often the whole fix.

Testing

  • A request to a valid public endpoint, and one to an internal endpoint whose certificate chains to the configured trust store, both return 200. A trust store holding only the internal CA is the common way to make a fix reject every public endpoint as well, and the failing-request tests below pass either way.
  • https://wrong.host.badssl.com/ throws SSLHandshakeException with No subject alternative DNS name matching .... Run this against every TLS client the application creates, including any raw SSLSocket, not only the one the finding named - a socket without setEndpointIdentificationAlgorithm("HTTPS") completes the handshake here and returns data.
  • https://self-signed.badssl.com/ and https://expired.badssl.com/ both throw SSLHandshakeException. Assert on the exception, not on the request failing: a misconfigured trust store fails with the same symptom for a different reason.
  • Start the application with -Djdk.internal.httpclient.disableHostnameVerification=true and confirm the wrong-host request still fails. If it succeeds, that flag is reachable in your deployment and the fix depends on nobody setting it.
  • Re-run the scanner that reported the finding and confirm it no longer triggers.

Additional Resources