Skip to content

CWE-295: Improper Certificate Validation - Go

Overview

Improper certificate validation vulnerabilities in Go applications occur when TLS/SSL certificate verification is disabled or implemented incorrectly, allowing man-in-the-middle (MITM) attacks. When establishing HTTPS connections, proper certificate validation ensures you're communicating with the legitimate server, not an attacker intercepting traffic. Go's crypto/tls package provides strong default certificate validation, but developers often disable it during development or when encountering certificate errors.

The most dangerous pattern is setting InsecureSkipVerify: true in tls.Config, which disables certificate chain and hostname verification unless correct custom verification is supplied. This allows attackers on the network path to intercept, read, and modify traffic by presenting their own certificates. The connection may still use TLS encryption, but without authentication it does not protect against an active MITM attacker. Other common mistakes include failing to verify certificate hostnames, accepting expired certificates, not checking certificate chains properly, or implementing custom verification logic with flaws.

They matter most where the traffic carries credentials: microservices talking to each other over mutual TLS, mobile apps making HTTPS requests, and connections to databases, message queues, or APIs over TLS. An attacker in the network path reads everything that crosses it - authentication tokens, passwords, personal data - and can rewrite responses, including configuration or code the client then acts on.

Primary Defence: Do not use InsecureSkipVerify: true in application code. Use Go's default certificate validation, which checks certificate chains, expiry, and hostname matching. For custom certificate authorities, add them to the trusted root store via tls.Config.RootCAs. For mutual TLS, configure the client certificate alongside RootCAs rather than in place of it.

Common Vulnerable Patterns

InsecureSkipVerify in Production

// VULNERABLE - Disabling certificate verification
package main

import (
    "crypto/tls"
    "fmt"
    "io"
    "net/http"
)

func fetchData(url string) ([]byte, error) {
    // DANGEROUS: Disable certificate verification
    tr := &http.Transport{
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: true, // NEVER DO THIS
        },
    }

    client := &http.Client{Transport: tr}

    resp, err := client.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

func main() {
    // VULNERABLE - All HTTPS requests are vulnerable to MITM
    data, _ := fetchData("https://api.example.com/sensitive-data")
    fmt.Printf("Data: %s\n", data)
}

// ATTACK:
// Attacker on network (WiFi, ISP, compromised router) intercepts connection
// Presents their own certificate, which client accepts without validation
// Attacker reads all data, including credentials, API keys, personal information
// Can also modify responses to inject malicious code/data

Why this is vulnerable: InsecureSkipVerify: true tells Go to accept any certificate, regardless of validity, expiration, or hostname. An attacker performing a MITM attack can present their own certificate (self-signed or for a different domain), intercept the encrypted connection, decrypt all traffic, read/modify it, re-encrypt with the legitimate server's connection, and forward it. The client has no way to detect this. The traffic is still encrypted - to the attacker rather than to the server.

Conditional Verification Based on Environment

// VULNERABLE - Environment-based skip verification
import (
    "crypto/tls"
    "net/http"
    "os"
)

func createHTTPClient() *http.Client {
    // DANGEROUS: Different behavior in dev vs prod
    config := &tls.Config{}

    if os.Getenv("ENVIRONMENT") == "development" {
        // VULNERABLE - Insecure config in dev
        config.InsecureSkipVerify = true
    }

    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: config,
        },
    }
}

// RISK:
// 1. Dev environment still handles real data - vulnerable to MITM
// 2. Environment variable might not be set correctly in production
// 3. Code designed to work insecurely makes it easy to ship vulnerabilities

Why this is vulnerable: Development and staging environments often handle real customer data and credentials. MITM attacks are possible on development networks too (coffee shop WiFi, shared networks, compromised dev machines). Relying on an environment variable adds a second failure mode - if ENVIRONMENT isn't set or is set incorrectly (e.g., "dev", "Development", "DEV" instead of "development"), the insecure code path executes in production. It also creates a culture where insecure configurations are normalized.

Custom Verification with Flawed Logic

// VULNERABLE - Flawed custom certificate verification
import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "net/http"
)

func createClientWithCustomVerify() *http.Client {
    config := &tls.Config{
        InsecureSkipVerify: true, // Disable default verification
        VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
            // DANGEROUS: Reimplementing verification incorrectly
            if len(rawCerts) == 0 {
                return fmt.Errorf("no certificates")
            }

            cert, err := x509.ParseCertificate(rawCerts[0])
            if err != nil {
                return err
            }

            // VULNERABLE - Only checks subject, not chain, expiry, or hostname
            if cert.Subject.Organization[0] == "Example Corp" {
                return nil
            }

            return fmt.Errorf("unknown organization")
        },
    }

    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: config,
        },
    }
}

// VULNERABILITY:
// - Doesn't verify certificate chain (trust path to root CA)
// - Doesn't check expiration date
// - Doesn't verify hostname matches requested domain
// - Organization can be spoofed by attacker

Why this is vulnerable: Certificate validation is complex - checking signatures, chains of trust, expiration dates, hostname matching, revocation status, and more. Setting InsecureSkipVerify: true and implementing VerifyPeerCertificate bypasses Go's default validation. The custom implementation only checks organization name, which an attacker writes as they please into a certificate they issue themselves. Without chain verification, the certificate doesn't need to be signed by a trusted CA. Without expiry checks, compromised old certificates remain valid. Without hostname verification, certificates for other domains are accepted. Measured on Go 1.25: a self-signed certificate, an expired one and one issued for another host all connect once they carry O=Example Corp. It is also fragile in the other direction - cert.Subject.Organization[0] panics with index out of range on a certificate that has no organisation at all, such as a public host's, and because the callback runs inside the handshake goroutine the panic takes the whole process down rather than failing one request.

Trust Anchors Loaded From a Writable Location

// VULNERABLE - trust anchors read from a file the process does not control
import (
    "crypto/tls"
    "crypto/x509"
    "net/http"
    "os"
)

func clientTrustingAnyCert() *http.Client {
    // Load a "self-signed" certificate
    cert, _ := os.ReadFile("untrusted-cert.pem")

    certPool := x509.NewCertPool()
    certPool.AppendCertsFromPEM(cert)

    // DANGEROUS: Trusting any certificate user provides
    config := &tls.Config{
        RootCAs: certPool,
    }

    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: config,
        },
    }
}

// RISK:
// If attacker can write to cert file location, they can install their CA
// Then perform MITM attacks on all connections using this client

Why this is vulnerable: Setting RootCAs is not the problem - the two secure patterns below do exactly that. What is wrong here is where the trust anchor comes from and what happens when reading it fails.

  • Provenance. The pool is built from a relative path in the working directory. Anyone who can write that file chooses what the application trusts, and they then need only one certificate signed by their own CA to read every connection this client makes. A trust anchor is a security decision baked into the deployment, so it belongs somewhere the running process cannot rewrite: compiled into the binary, mounted read-only, or delivered by the platform's secret store.
  • Silent failure. Both errors are discarded. os.ReadFile returning an error leaves cert empty, AppendCertsFromPEM returns false on an empty or malformed input, and the result is a non-nil but empty RootCAs - which is not the same as nil. Go only falls back to the host roots when RootCAs is nil, so this client rejects every certificate in existence, and the failure surfaces as a handshake error from an unrelated deployment change. Check both return values and refuse to start.

Secure Patterns

Use Default Certificate Validation

// SECURE - Go's default certificate validation
package main

import (
    "fmt"
    "io"
    "net/http"
)

func secureFetchData(url string) ([]byte, error) {
    // SECURE - Create standard HTTP client with default TLS config
    // No custom TLS configuration = secure defaults
    client := &http.Client{}

    resp, err := client.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

func main() {
    data, err := secureFetchData("https://api.example.com/data")
    if err != nil {
        // Handle error - don't disable verification to "fix" it
        fmt.Printf("Request failed: %v\n", err)
        return
    }

    fmt.Printf("Data: %s\n", data)
}

Why this works: Go's default HTTP client uses comprehensive certificate validation: verifies certificate chains up to trusted root CAs, checks expiration dates, validates hostname matches the requested URL, and ensures proper cryptographic signatures. Go normally uses the platform verifier or system certificate pool; fallback roots are used only when explicitly configured with x509.SetFallbackRoots and applicable runtime settings. No custom tls.Config means no opportunity for misconfiguration. If certificate validation fails, the error indicates a real problem (expired cert, wrong hostname, untrusted CA) that must be fixed at the source, not bypassed in code.

Custom Root CAs for Internal PKI

// SECURE - Properly configuring custom root CAs
import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "io"
    "net/http"
)

// SECURE - Embed trusted CA certificate in binary
const internalCA = `-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKL0UG+mRKU7MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
... (your internal CA certificate) ...
-----END CERTIFICATE-----`

func createSecureInternalClient() (*http.Client, error) {
    // SECURE - Parse embedded CA certificate
    certPool := x509.NewCertPool()
    if !certPool.AppendCertsFromPEM([]byte(internalCA)) {
        return nil, fmt.Errorf("failed to parse CA certificate")
    }

    // SECURE - Use custom CA pool while maintaining other validations
    config := &tls.Config{
        RootCAs:    certPool,
        MinVersion: tls.VersionTLS12,
        // InsecureSkipVerify: false (default, explicit for clarity)
    }

    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: config,
        },
    }, nil
}

func fetchInternalData(url string) ([]byte, error) {
    client, err := createSecureInternalClient()
    if err != nil {
        return nil, err
    }

    resp, err := client.Get(url)
    if err != nil {
        return nil, fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

Why this works: The internal CA certificate is embedded in the binary as a constant, where the running process cannot rewrite it. x509.NewCertPool() creates the pool, AppendCertsFromPEM() adds the internal CA, and a parse failure returns an error instead of leaving an empty pool behind. The tls.Config uses that pool while every other check stays in place: certificates signed by the internal CA are trusted, but chain, expiry and hostname verification still run. MinVersion: tls.VersionTLS12 sets the floor on protocol version. This is the pattern for internal services on a private PKI.

System CA Pool Plus Custom CAs

// SECURE - Combine system CAs with custom CAs
import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "net/http"
)

func createCombinedCAClient() (*http.Client, error) {
    // SECURE - Start with system CA pool
    certPool, err := x509.SystemCertPool()
    if err != nil {
        return nil, fmt.Errorf("failed to load system CA pool: %w", err)
    }

    // Add internal CA(s) to system pool
    internalCAs := []string{
        `-----BEGIN CERTIFICATE-----
        ... internal CA 1 ...
        -----END CERTIFICATE-----`,
        `-----BEGIN CERTIFICATE-----
        ... internal CA 2 ...
        -----END CERTIFICATE-----`,
    }

    for i, ca := range internalCAs {
        if !certPool.AppendCertsFromPEM([]byte(ca)) {
            return nil, fmt.Errorf("failed to parse CA %d", i)
        }
    }

    config := &tls.Config{
        RootCAs:    certPool,
        MinVersion: tls.VersionTLS12,
    }

    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: config,
        },
    }, nil
}

Why this works: This is the pattern for a client that calls both public and internal endpoints. Starting from x509.SystemCertPool() rather than x509.NewCertPool() is what keeps the public roots reachable; appending the internal CAs adds the private ones. If the system pool cannot be loaded, fail fast rather than silently continuing with an internal-CA-only trust store, which would break every public call while looking like a hardening step. MinVersion: tls.VersionTLS12 disables older TLS versions. Leaving CipherSuites unset lets Go use its maintained safe defaults for TLS 1.0-1.2; TLS 1.3 cipher suites are not configurable through that field.

How the pool reaches the public roots differs by platform, which matters when you go to debug it. On Linux it really does read the CA files from disk. On Windows and macOS it comes back empty - len(pool.Subjects()) is 0, measured on Go 1.25 - and carries a flag that tells Verify to hand the certificate to the OS verifier instead. Certificates appended afterwards do not disable that: Go tries the platform verifier first and falls back to its own checks against the appended roots, so both halves work. The trap is only for code that inspects the pool - a health check asserting the trust store is non-empty passes on Linux and fails on Windows against an identical, working configuration.

Mutual TLS with Client Certificates

// SECURE - Mutual TLS (mTLS) with client certificates
import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "io"
    "net/http"
    "os"
)

// The CA is a public value and can be embedded. The client's private key is not:
// keep it out of the source tree and out of the binary, and read it at startup
// from a mounted secret, a KMS, or the platform's certificate store.
const serverCA = `-----BEGIN CERTIFICATE-----
... server CA certificate ...
-----END CERTIFICATE-----`

func createMutualTLSClient(clientCertPath, clientKeyPath string) (*http.Client, error) {
    // SECURE - Load server CA pool
    serverCAPool := x509.NewCertPool()
    if !serverCAPool.AppendCertsFromPEM([]byte(serverCA)) {
        return nil, fmt.Errorf("failed to parse server CA")
    }

    // SECURE - Load client certificate and key from files the process can read
    // but the repository never sees
    cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
    if err != nil {
        return nil, fmt.Errorf("failed to load client cert: %w", err)
    }

    config := &tls.Config{
        RootCAs:      serverCAPool,
        Certificates: []tls.Certificate{cert},
        MinVersion:   tls.VersionTLS12,
    }

    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: config,
        },
    }, nil
}

func callMutualTLSService(url string) ([]byte, error) {
    client, err := createMutualTLSClient(
        os.Getenv("CLIENT_CERT_PATH"), os.Getenv("CLIENT_KEY_PATH"))
    if err != nil {
        return nil, err
    }

    resp, err := client.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

Why this works: Mutual TLS authenticates in both directions - the client verifies the server's certificate, which is what stops the MITM, and the server verifies the client's, which is what replaces the API key. RootCAs holds the CA that signed the server's certificate; Certificates holds the client's certificate and private key, presented during the handshake and checked by the server against its own trusted CA pool. Note that the client half is authentication, not certificate validation: presenting a client certificate does nothing about CWE-295 on its own, and an mTLS client with InsecureSkipVerify: true is still fully interceptable. Both fields have to be set.

The private key is the reason this example reads paths rather than constants. A key compiled into the binary is readable with strings, ships to every host that gets the image, and cannot be rotated without a rebuild - and unlike the CA certificate, it is the secret that the whole scheme rests on.

Custom Server Name Verification

// SECURE - Verifying certificate hostname for specific scenarios
import (
    "crypto/tls"
    "net/http"
)

func createClientWithSNIOverride(serverName string) *http.Client {
    config := &tls.Config{
        ServerName: serverName, // Override SNI for IP-based connections
        MinVersion: tls.VersionTLS12,
        // Default verification still occurs
    }

    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: config,
        },
    }
}

// Use case: Connecting to IP address but verifying specific hostname
func connectToIPWithHostnameVerification() error {
    // Server certificate is for "api.example.com" but connecting via IP
    client := createClientWithSNIOverride("api.example.com")

    // Connect via IP, but verify certificate is for api.example.com
    resp, err := client.Get("https://192.0.2.10/data")
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    return nil
}

Why this works: ServerName sets both the Server Name Indication sent in the handshake and the name the certificate is checked against. The connection goes to 192.0.2.10, the handshake announces api.example.com, and validation matches the certificate's Subject Alternative Names against api.example.com rather than against the address. Chain, expiry and signature checks are untouched. This is the correct way to reach a host by address - the alternative people reach for, InsecureSkipVerify: true, gives up the whole certificate check to solve a naming problem.

ServerName belongs to the tls.Config, so it applies to every host that client contacts, not to one request. The constructor above returns a client dedicated to a single service, which is why it is safe here; reuse it as a general-purpose client and the second destination fails. Measured on Go 1.25: a client built with ServerName: "www.google.com" fetches https://www.google.com/ with 200 OK and fails https://example.com/ with remote error: tls: handshake failure - the server rejected an SNI naming someone else. Keep one client per pinned name, and give anything that talks to more than one host a config without ServerName.

Testing with Local Certificates

// SECURE - Proper testing with local certificates
//go:build integration
// +build integration

package main_test

import (
    "crypto/tls"
    "crypto/x509"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestHTTPSEndpoint(t *testing.T) {
    // SECURE - Use httptest with TLS for integration tests
    server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("test response"))
    }))
    defer server.Close()

    // SECURE - Trust the test server's certificate explicitly
    // This is safe because server.Client() provides properly configured client
    client := server.Client()

    resp, err := client.Get(server.URL)
    if err != nil {
        t.Fatalf("Request failed: %v", err)
    }
    defer resp.Body.Close()

    // Test assertions
    if resp.StatusCode != http.StatusOK {
        t.Errorf("Expected 200, got %d", resp.StatusCode)
    }
}

// Alternative: Manual test client configuration
func createTestClient(serverCert *x509.Certificate) *http.Client {
    certPool := x509.NewCertPool()
    certPool.AddCert(serverCert)

    config := &tls.Config{
        RootCAs: certPool,
    }

    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: config,
        },
    }
}

Why this works: httptest.NewTLSServer creates a test server with a self-signed certificate and provides server.Client(), which is pre-configured to trust that certificate and nothing else. The HTTPS endpoint gets exercised with validation switched on and no InsecureSkipVerify anywhere in the test. For manual configuration, certPool.AddCert() adds the specific test certificate to a custom pool, maintaining full validation for that certificate.

Kubernetes API Client (client-go)

Applications that communicate with the Kubernetes API server via client-go can also bypass certificate validation.

// VULNERABLE - Disabling TLS verification with client-go
import (
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/rest"
)

func createInsecureK8sClient() (*kubernetes.Clientset, error) {
    config := &rest.Config{
        Host: "https://kubernetes.api.example.com",
        TLSClientConfig: rest.TLSClientConfig{
            Insecure: true, // VULNERABLE - disables all TLS verification
        },
    }
    return kubernetes.NewForConfig(config)
}

Why this is vulnerable: Insecure: true in rest.TLSClientConfig disables all certificate validation for API server communication, exposing service account tokens and all cluster traffic to MITM attacks.

// SECURE - Use in-cluster config when running as a pod
import (
    "fmt"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/rest"
    "k8s.io/client-go/tools/clientcmd"
)

// Running inside the cluster: automatically loads the service account CA bundle
// and token from /var/run/secrets/kubernetes.io/serviceaccount/
func createInClusterK8sClient() (*kubernetes.Clientset, error) {
    config, err := rest.InClusterConfig()
    if err != nil {
        return nil, fmt.Errorf("failed to load in-cluster config: %w", err)
    }
    return kubernetes.NewForConfig(config)
}

// Running outside the cluster (CI/CD, local tooling): load from kubeconfig
func createExternalK8sClient(kubeconfigPath string) (*kubernetes.Clientset, error) {
    config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath)
    if err != nil {
        return nil, fmt.Errorf("failed to load kubeconfig: %w", err)
    }
    // Reject kubeconfig files that have insecure-skip-tls-verify: true
    if config.TLSClientConfig.Insecure {
        return nil, fmt.Errorf("kubeconfig has TLS verification disabled - fix insecure-skip-tls-verify")
    }
    return kubernetes.NewForConfig(config)
}

Why this works: rest.InClusterConfig() automatically loads the service account CA bundle and token mounted by Kubernetes, providing authenticated and certificate-validated communication with the API server. For out-of-cluster use, clientcmd.BuildConfigFromFlags honours the cluster CA defined in kubeconfig, and the explicit Insecure check catches any kubeconfig files that inadvertently set insecure-skip-tls-verify: true.

Additional Resources